Token-0x

First Flight #54
Beginner FriendlyDeFi
100 EXP
Submission Details
Impact: medium
Likelihood: high

_spendAllowance Called Before _transfer Leads to Allowance Loss Without Token Transfer

Author Revealed upon completion

Description

  • In a standard ERC20 token, the transferFrom function allows an approved spender to transfer tokens on behalf of the token owner. The allowance should only be decremented when the token transfer is successfully completed.

  • The transferFrom function calls _spendAllowance before _transfer, which means the allowance is decremented before verifying the transfer succeeds. When _transfer returns false without reverting, the transaction completes but no tokens are moved, yet the allowance is permanently reduced.

// src/ERC20.sol
function transferFrom(address from, address to, uint256 value) public virtual returns (bool success) {
address spender = msg.sender;
@> _spendAllowance(from, spender, value);
@> success = _transfer(from, to, value);
}

Risk:

  • A DEX or smart contract calls transferFrom to execute a swap on behalf of a user who has granted an allowance.

  • User must re-approve: Requires a new transaction and gas costs to restore the allowance

  • Protocol integration breaks: Any protocol relying on transferFrom may enter inconsistent states

Likelihood:

  • The recipient contract has hooks that reject incoming transfers, causing _transfer to return false without reverting.

Impact:

  • The spender's allowance is permanently reduced to zero even though no tokens were transferred.

  • The DEX can no longer pull the approved funds, locking the user's intended operation until a new manual approval is granted.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {Token} from "./Token.sol";
/// @notice Token that can simulate _transfer returning false
contract VulnerableToken is Token {
mapping(address => bool) public blockedRecipients;
function blockRecipient(address recipient) external {
blockedRecipients[recipient] = true;
}
/// @notice Override transferFrom to simulate _transfer returning false for blocked recipients
function transferFrom(address from, address to, uint256 value) public override returns (bool success) {
address spender = msg.sender;
_spendAllowance(from, spender, value); // Allowance consumed first
if (blockedRecipients[to]) {
return false; // Transfer fails but no revert - allowance already spent!
}
success = _transfer(from, to, value);
}
}
/// @notice Mock DEX contract
contract MockDEX {
function executeSwap(address token, address from, address to, uint256 amount) external returns (bool) {
return VulnerableToken(token).transferFrom(from, to, amount);
}
}
contract TransferFromVulnerabilityTest is Test {
VulnerableToken public token;
MockDEX public dex;
address public userA;
address public recipientB;
function setUp() public {
token = new VulnerableToken();
dex = new MockDEX();
userA = makeAddr("userA");
recipientB = makeAddr("recipientB");
token.mint(userA, 1000e18);
}
function test_allowanceConsumedOnFailedTransfer() public {
// 1. User A approves DEX to spend 1,000 tokens
vm.prank(userA);
token.approve(address(dex), 1000e18);
assertEq(token.allowance(userA, address(dex)), 1000e18);
// 2. Recipient B is blocked (simulates hook rejection)
token.blockRecipient(recipientB);
// 3. DEX calls transferFrom
bool success = dex.executeSwap(address(token), userA, recipientB, 1000e18);
// 4. Transfer failed
assertFalse(success, "Transfer should fail");
// 5. VULNERABILITY: Allowance consumed even though no tokens transferred!
assertEq(token.allowance(userA, address(dex)), 0, "Allowance was consumed");
assertEq(token.balanceOf(userA), 1000e18, "User A still has all tokens");
assertEq(token.balanceOf(recipientB), 0, "Recipient B got nothing");
// 6. DEX can no longer pull the tokens
vm.expectRevert();
dex.executeSwap(address(token), userA, makeAddr("validRecipient"), 1);
}
}

when we run the etst we have

(base) ➜ 2025-12-token-0x git:(main) ✗ forge test --match-path test/TransferFromVulnerability.t.sol -vvv
[⠊] Compiling...
[⠒] Compiling 1 files with Solc 0.8.30
[⠑] Solc 0.8.30 finished in 590.51ms
Compiler run successful!
Ran 1 test for test/TransferFromVulnerability.t.sol:TransferFromVulnerabilityTest
[PASS] test_allowanceConsumedOnFailedTransfer() (gas: 76615)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 6.93ms (1.35ms CPU time)

}

Recommended Mitigation

Move _spendAllowance after _transfer to ensure allowance is only consumed on successful transfers:

function transferFrom(address from, address to, uint256 value) public virtual returns (bool success) {
address spender = msg.sender;
- _spendAllowance(from, spender, value);
- success = _transfer(from, to, value);
+ success = _transfer(from, to, value);
+ if (success) {
+ _spendAllowance(from, spender, value);
+ }
}

Support

FAQs

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

Give us feedback!