<100 subscribers

There’s a pool offering rewards in tokens every 5 days for those who deposit their DVT tokens into it. Alice, Bob, Charlie and David have already deposited some DVT tokens, and have won their rewards! You don’t have any DVT tokens. But in the upcoming round, you must claim most rewards for yourself. Oh, by the way, rumours say a new pool has just landed on mainnet. Isn’t it offering DVT tokens in flash loans?
FlashLoanerPool.sol

constructor(address liquidityTokenAddress)
Used as a liquidity token contract management variable. (liquidityToken)
function flashLoan(uint256 amount) external nonReentrant
Perform simple flash loan logic. Use the liquidityToken contract function assigned in the initial deployment phase.
✅ If the value received as a function argument is less than or equal to the value of Liquidity Token, the condition can be passed.
✅ Borrower can pass if it is in the deployed contract state.
Based on the transfer function of the liquidityToken contract, it is transferred to the msg.sender target.
Call the receiverFlashLoan(uint256 amount) function by executing functionCall based on msg.sender. There is currently no implementation.
✅ The balance value of the liquidityToken contract must be greater than or equal to the previous Balance.
TheRewarderPool.sol


uint256 private constant REWARDS_ROUND_MIN_DURATION = 5 days
Used to measure the minimum duration of a reward round. (in seconds, 24 * 60 * 60 * 5)
uint256 public lastSnapshotIdForRewards
uint256 public lastRecordedSnapshotTimestamp
uint256 public roundNumber;
The dependent variables used in the reward payment logic are updated if the current time is greater than or equal to the value of the lastRecordedSnapshotTimestamp variable plus the value of REWARDS_ROUND_MIN_DURATION. When this occurs, the snapshot is nominally updated and the value of each round is also updated.
You can check the reward tracking function to see the pattern that must be followed in order for the reward logic to be executed in units of 5 days.
mapping(address => uint256) public lastRewardTimestamps;
The _hasRetrievedReward function is used to determine the value in the mapping structure. You can see that the address of the user who will receive each reward is the key, and the value is the time when the reward was received.
constructor(address tokenAddress)
Allocate and initialize variables for liquidityToken, AccountToken, and RewardToken contract control.
_recordSnapshot(); The function that initializes the snapshot function is called directly from within, and this is a function that is called once when deploying the contract.
function deposit(uint256 amountToDeposit) external
✅ The value received as a function argument must be unconditionally greater than 0 to pass.
Based on the mint function of the AccountToken contract, it is created as much as the argument value to msg.sender
To tramsfer, a token must exist for each user and must be allowed through the approve function.
Call distributeRewards() function. (It is assumed that the work of distributing rewards is in progress.)
✅ Call the transferFrom function of the liquidityToken contract to transfer the tokens held by msg.sender to the current contract as much as the argument of the parent function.
function withdraw(uint256 amountToWithdraw) external
Based on the burn function of the AccountToken contract, the token of msg.sender is destroyed as much as the argument value of the parent function.
✅ Pass the value to msg.sender based on the transfer function of the liquidityToken contract.
function distributeRewards() public returns (uint256)
This is a core function function that proceeds with the issuance of rewards.
Calls the isNewRewardsRound function to determine whether a reward can be received and when an actual reward is received, the
_recordSnapshot() function is called to change the reward status, time, and round status values.
function isNewRewardsRound() public view returns (bool) {
return block.timestamp >= lastRecordedSnapshotTimestamp + REWARDS_ROUND_MIN_DURATION;
}
In order to receive a reward, first deposit the token, pass this function after 5 days, record a snapshot and calculate each reward before receiving the value.
Now, let's analyze the reward logic in more detail. Snapshots and rewards are closely related, so let's consider a hypothetical scenario where we calculate the value that a user can receive after 5 days of depositing 10 ether.

In the initial stage after depositing, the balance value is allocated to the pool. After 5 days have elapsed, in round 3, the conditions for receiving a reward are met and the reward value is 1 ether. At this point, the snapshot records a balance of 10 ether.

// [OMIT]
uint256 totalDeposits = accToken.totalSupplyAt(lastSnapshotIdForRewards);
uint256 amountDeposited = accToken.balanceOfAt(msg.sender, lastSnapshotIdForRewards);
if (amountDeposited > 0 && totalDeposits > 0) {
rewards = (amountDeposited * 100 * 10 ** 18) / totalDeposits;
if(rewards > 0 && !_hasRetrievedReward(msg.sender)) {
rewardToken.mint(msg.sender, rewards);
lastRewardTimestamps[msg.sender] = block.timestamp;
}
}
// [OMIT]
If the withdraw function is called in the Round: 3 situation, the Balance value of the Account Pool can be retrieved. As shown in the figure above, the Balance value becomes 0, but it can be seen that the Snapshot and Reward continue to maintain the value.
RewardToken.sol

bytes32 public constant MINTER_ROLE = keccak256(“MINTER_ROLE”);
Since we use the RAC function of the AccessControl contract, we hash each Role to keccak256.
constructor() ERC20(“Reward Token”, “RWT”)
Proceed with ERC20 token initial setting and set RAC function through _setupRole function.
function mint(address to, uint256 amount) external
✅ Check whether the MINTER_ROLE value is allowed through the hasRole function and then check whether msg.senderis allowed.
Call the _mint function.
AccountingToken.sol

constructor() ERC20(“rToken”, “rTKN”)
Proceed with ERC20 initial setting and set RAC for each ROLE designated as state variable through _setupRole function.
function mint(address to, uint256 amount) external
✅ Check if MINTER_ROLE ROLE is msg.sender with hasRole function.
Call the _mint function.
function burn(address from, uint256 amount) external
✅ Check if BURNER_ROLE ROLE is msg.sender with hasRole function.
Call the _burn function.
function snapshot() external returns (uint256)
✅ Check if SNAPSHOT_ROLE ROLE is msg.sender with hasRole function.
Execute the ERC20 snapshot logic by calling the _snapshot function.
/**
* @dev Creates a new snapshot and returns its snapshot id.
*
* Emits a {Snapshot} event that contains the same id.
*
* {_snapshot} is `internal` and you have to decide how to expose it externally. Its usage may be restricted to a
* set of accounts, for example using {AccessControl}, or it may be open to the public.
*
* [WARNING]
* ====
* While an open way of calling {_snapshot} is required for certain trust minimization mechanisms such as forking,
* you must consider that it can potentially be used by attackers in two ways.
*
* First, it can be used to increase the cost of retrieval of values from snapshots, although it will grow
* logarithmically thus rendering this attack ineffective in the long term. Second, it can be used to target
* specific accounts and increase the cost of ERC20 transfers for them, in the ways specified in the Gas Costs
* section above.
*
* We haven't measured the actual numbers; if this is something you're interested in please reach out to us.
* ====
*/
function _snapshot() internal virtual returns (uint256) {
_currentSnapshotId.increment();
uint256 currentId = _getCurrentSnapshotId();
emit Snapshot(currentId);
return currentId;
}
If you analyze the implementation code, you can see that the ID value is issued in increments using the _currentSnapshotId Counter instance value, the transaction event is recorded, and the current ID value is returned through the _getCurrentSnapshotId() function.

Since the contract is composed of each function, each function plays a certain role, and the main function determines the dependency to create a business flow and analyze it.
During our audit, we identified the function of each contract. The Snapshot function is currently used in the reward issuance process, and we found that the Balance value and the reward value are not checked against each other during the actual withdrawal process. However, since this process occurs after making an actual deposit, a line operation is required.
An attacker can exploit this vulnerability by calling the FlashLoan function in the FlashLoanPool contract to obtain LiquidityToken, and then executing the reward vulnerability logic to steal the balance. The FlashLoan function inside the FlashLoanPool contract performs a transfer and lends the user as much as the value in the pool, and then calls the receiveFlashLoan(uint) function, which has not yet been implemented. At this time, after importing a contract deployed from an arbitrarily implemented contract, there is a vector that can implement the receiveFlashLoan function.
After borrowing the entire balance from the flashLoanPool, the attacker deposits it in the reward pool to execute the reward logic. At this time, based on the reward snapshot management vulnerability, the attacker can call withdraw from this pool to withdraw the balance and pay off the flashLoan loan.
If an attacker manipulates a transaction to issue a reward after arbitrarily waiting for 5 days, as described above, the actual reward value will still be available due to the snapshot function, allowing the attacker to steal it.



Thank you for the @tinchoabbate that made a good wargame.

There’s a pool offering rewards in tokens every 5 days for those who deposit their DVT tokens into it. Alice, Bob, Charlie and David have already deposited some DVT tokens, and have won their rewards! You don’t have any DVT tokens. But in the upcoming round, you must claim most rewards for yourself. Oh, by the way, rumours say a new pool has just landed on mainnet. Isn’t it offering DVT tokens in flash loans?
FlashLoanerPool.sol

constructor(address liquidityTokenAddress)
Used as a liquidity token contract management variable. (liquidityToken)
function flashLoan(uint256 amount) external nonReentrant
Perform simple flash loan logic. Use the liquidityToken contract function assigned in the initial deployment phase.
✅ If the value received as a function argument is less than or equal to the value of Liquidity Token, the condition can be passed.
✅ Borrower can pass if it is in the deployed contract state.
Based on the transfer function of the liquidityToken contract, it is transferred to the msg.sender target.
Call the receiverFlashLoan(uint256 amount) function by executing functionCall based on msg.sender. There is currently no implementation.
✅ The balance value of the liquidityToken contract must be greater than or equal to the previous Balance.
TheRewarderPool.sol


uint256 private constant REWARDS_ROUND_MIN_DURATION = 5 days
Used to measure the minimum duration of a reward round. (in seconds, 24 * 60 * 60 * 5)
uint256 public lastSnapshotIdForRewards
uint256 public lastRecordedSnapshotTimestamp
uint256 public roundNumber;
The dependent variables used in the reward payment logic are updated if the current time is greater than or equal to the value of the lastRecordedSnapshotTimestamp variable plus the value of REWARDS_ROUND_MIN_DURATION. When this occurs, the snapshot is nominally updated and the value of each round is also updated.
You can check the reward tracking function to see the pattern that must be followed in order for the reward logic to be executed in units of 5 days.
mapping(address => uint256) public lastRewardTimestamps;
The _hasRetrievedReward function is used to determine the value in the mapping structure. You can see that the address of the user who will receive each reward is the key, and the value is the time when the reward was received.
constructor(address tokenAddress)
Allocate and initialize variables for liquidityToken, AccountToken, and RewardToken contract control.
_recordSnapshot(); The function that initializes the snapshot function is called directly from within, and this is a function that is called once when deploying the contract.
function deposit(uint256 amountToDeposit) external
✅ The value received as a function argument must be unconditionally greater than 0 to pass.
Based on the mint function of the AccountToken contract, it is created as much as the argument value to msg.sender
To tramsfer, a token must exist for each user and must be allowed through the approve function.
Call distributeRewards() function. (It is assumed that the work of distributing rewards is in progress.)
✅ Call the transferFrom function of the liquidityToken contract to transfer the tokens held by msg.sender to the current contract as much as the argument of the parent function.
function withdraw(uint256 amountToWithdraw) external
Based on the burn function of the AccountToken contract, the token of msg.sender is destroyed as much as the argument value of the parent function.
✅ Pass the value to msg.sender based on the transfer function of the liquidityToken contract.
function distributeRewards() public returns (uint256)
This is a core function function that proceeds with the issuance of rewards.
Calls the isNewRewardsRound function to determine whether a reward can be received and when an actual reward is received, the
_recordSnapshot() function is called to change the reward status, time, and round status values.
function isNewRewardsRound() public view returns (bool) {
return block.timestamp >= lastRecordedSnapshotTimestamp + REWARDS_ROUND_MIN_DURATION;
}
In order to receive a reward, first deposit the token, pass this function after 5 days, record a snapshot and calculate each reward before receiving the value.
Now, let's analyze the reward logic in more detail. Snapshots and rewards are closely related, so let's consider a hypothetical scenario where we calculate the value that a user can receive after 5 days of depositing 10 ether.

In the initial stage after depositing, the balance value is allocated to the pool. After 5 days have elapsed, in round 3, the conditions for receiving a reward are met and the reward value is 1 ether. At this point, the snapshot records a balance of 10 ether.

// [OMIT]
uint256 totalDeposits = accToken.totalSupplyAt(lastSnapshotIdForRewards);
uint256 amountDeposited = accToken.balanceOfAt(msg.sender, lastSnapshotIdForRewards);
if (amountDeposited > 0 && totalDeposits > 0) {
rewards = (amountDeposited * 100 * 10 ** 18) / totalDeposits;
if(rewards > 0 && !_hasRetrievedReward(msg.sender)) {
rewardToken.mint(msg.sender, rewards);
lastRewardTimestamps[msg.sender] = block.timestamp;
}
}
// [OMIT]
If the withdraw function is called in the Round: 3 situation, the Balance value of the Account Pool can be retrieved. As shown in the figure above, the Balance value becomes 0, but it can be seen that the Snapshot and Reward continue to maintain the value.
RewardToken.sol

bytes32 public constant MINTER_ROLE = keccak256(“MINTER_ROLE”);
Since we use the RAC function of the AccessControl contract, we hash each Role to keccak256.
constructor() ERC20(“Reward Token”, “RWT”)
Proceed with ERC20 token initial setting and set RAC function through _setupRole function.
function mint(address to, uint256 amount) external
✅ Check whether the MINTER_ROLE value is allowed through the hasRole function and then check whether msg.senderis allowed.
Call the _mint function.
AccountingToken.sol

constructor() ERC20(“rToken”, “rTKN”)
Proceed with ERC20 initial setting and set RAC for each ROLE designated as state variable through _setupRole function.
function mint(address to, uint256 amount) external
✅ Check if MINTER_ROLE ROLE is msg.sender with hasRole function.
Call the _mint function.
function burn(address from, uint256 amount) external
✅ Check if BURNER_ROLE ROLE is msg.sender with hasRole function.
Call the _burn function.
function snapshot() external returns (uint256)
✅ Check if SNAPSHOT_ROLE ROLE is msg.sender with hasRole function.
Execute the ERC20 snapshot logic by calling the _snapshot function.
/**
* @dev Creates a new snapshot and returns its snapshot id.
*
* Emits a {Snapshot} event that contains the same id.
*
* {_snapshot} is `internal` and you have to decide how to expose it externally. Its usage may be restricted to a
* set of accounts, for example using {AccessControl}, or it may be open to the public.
*
* [WARNING]
* ====
* While an open way of calling {_snapshot} is required for certain trust minimization mechanisms such as forking,
* you must consider that it can potentially be used by attackers in two ways.
*
* First, it can be used to increase the cost of retrieval of values from snapshots, although it will grow
* logarithmically thus rendering this attack ineffective in the long term. Second, it can be used to target
* specific accounts and increase the cost of ERC20 transfers for them, in the ways specified in the Gas Costs
* section above.
*
* We haven't measured the actual numbers; if this is something you're interested in please reach out to us.
* ====
*/
function _snapshot() internal virtual returns (uint256) {
_currentSnapshotId.increment();
uint256 currentId = _getCurrentSnapshotId();
emit Snapshot(currentId);
return currentId;
}
If you analyze the implementation code, you can see that the ID value is issued in increments using the _currentSnapshotId Counter instance value, the transaction event is recorded, and the current ID value is returned through the _getCurrentSnapshotId() function.

Since the contract is composed of each function, each function plays a certain role, and the main function determines the dependency to create a business flow and analyze it.
During our audit, we identified the function of each contract. The Snapshot function is currently used in the reward issuance process, and we found that the Balance value and the reward value are not checked against each other during the actual withdrawal process. However, since this process occurs after making an actual deposit, a line operation is required.
An attacker can exploit this vulnerability by calling the FlashLoan function in the FlashLoanPool contract to obtain LiquidityToken, and then executing the reward vulnerability logic to steal the balance. The FlashLoan function inside the FlashLoanPool contract performs a transfer and lends the user as much as the value in the pool, and then calls the receiveFlashLoan(uint) function, which has not yet been implemented. At this time, after importing a contract deployed from an arbitrarily implemented contract, there is a vector that can implement the receiveFlashLoan function.
After borrowing the entire balance from the flashLoanPool, the attacker deposits it in the reward pool to execute the reward logic. At this time, based on the reward snapshot management vulnerability, the attacker can call withdraw from this pool to withdraw the balance and pay off the flashLoan loan.
If an attacker manipulates a transaction to issue a reward after arbitrarily waiting for 5 days, as described above, the actual reward value will still be available due to the snapshot function, allowing the attacker to steal it.



Thank you for the @tinchoabbate that made a good wargame.
Share Dialog
Share Dialog
No comments yet