
Paradigm CTF 是區塊鏈產業最頂級、知名度最高的針對智慧合約駭客的線上競賽,由web3 頂級投資公司Paradigm 組織,CTF 主題由Sumczsun 和受到邀請的客座作者創造的多項挑戰組成。每一項挑戰的目標都是破解或透過攻擊技術解決問題。
在比賽期間,參賽者將完成一系列軟件謎題挑戰。在挑戰期結束前,參與者正確解決或獲得最高分數的每個挑戰都將獲得分數。對於山丘之王挑戰賽,將根據Elo 評分系統進行評分。每個正確解決挑戰的參與者將獲得的分數要到挑戰期結束後才能知道。
Salus 安全團隊共解決了13 項挑戰,在1011 支隊伍中以3645.60 的分數獲得第九名,並受邀成為Paradigm CTF 2024 的客座作者。在這篇文章中,我們將介紹我們在比賽期間解決的所有挑戰。

已解決的挑戰
Hello World
Black Sheep
100%
Dai++
DoDont
Grains of Sand
Suspicious Charity
Token Locker
Skill Based Game
Enterprise Blockchain
Dragon Tyrant
Hopping Into Place
Oven
1. Hello World
這個挑戰的目標是確保目標地址的ETH 餘額至少比之前多13.37 。
我們創建了兩個合約:一個測試合約SolveTest 和一個執行操作的合約Solve。 SolveTest 合約透過設定初始環境和執行測試攻擊來驗證挑戰是否已解決。Solve 合約透過killMySelf() 函數中的selfdestruct 操作將資金轉移到目標地址,從而達到增加目標地址ETH 餘額的目的。

2. Black Sheep
這個挑戰的目標是從BANK 合約中提取所有的ETH。漏洞存在於WITHDRAW() 函數中,由於CHECKSIG() 函數沒有正確處理返回值,在某些情況下直接結束執行而未將任何值推入棧,使得返回值錯誤地被讀取為CHECKVALUE() 的執行結果。我們的解決方案是編寫了一個Solver 合約,利用WITHDRAW() 函數的漏洞,透過確保CHECKVALUE() 返回0 ,使得WITHDRAW() 函數成功執行並從BANK 合約中提取了所有的ETH。
漏洞分析
我們研究了WITHDRAW() 函數,該函數會先依序執行CHECKVALUE() 和CHECKSIG() 函數,然後根據執行結果,將合約的所有ETH 傳送到msg.sender。其中,CHECKSIG() 函數沒有正確地處理函數傳回值。此函數需要在結束函數執行前,將一個結果推入堆疊作為傳回值。但在某些情況下,該函數直接結束執行而未將任何值推入棧,導致返回值錯誤地被讀取為棧頂的第一個元素,即CHECKVALUE() 函數的執行結果。由於CHECKSIG() 函數的設計缺陷,即使簽章驗證失敗,也可以透過確保CHECKVALUE() 函數傳回0 來使WITHDRAW() 函數成功執行。

在CHECKSIG() 函式中,呼叫WITHDRAW() 函式時使用輸入參數(bytes 32, uint 8, bytes 32, bytes 32)來呼叫位址0x 1 。這個合約是一個預編譯合約,其功能是基於參數恢復公鑰地址。這裡有兩個檢查。第一個是檢查簽名是否有效。如果staticcall 執行成功,表示簽章有效,所以輸入參數的內容並不重要。公鑰的正確性在第二個檢查。如果公鑰地址不正確,它不會回退,而是直接跳到函數的結尾。這個函數有一個回傳值,根據正常執行,需要在結束函數執行之前將一個結果推入堆疊作為回傳值。
然而,如果執行直接結束,沒有將任何值推入堆疊。這將導致回傳值被錯誤地讀取為堆疊頂部的第一個元素,即CHECKVALUE() 的執行結果。因此,只要CHECKVALUE() 函數的執行結果傳回0 ,WITHDRAW() 函數就可以順利執行,並成功將10 ETH 傳送到msg.sender。

我們希望CHECKVALUE() 函數的執行結果為0 ,即棧頂元素為0 。我們只需要滿足「0x 10 > callvalue」 來讓呼叫操作失敗。

解決方案
我們編寫了Solver 合約來從Bank 合約提款。 Bank 合約中的ETH 是透過WITHDRAW() 函數中的call 操作傳送到Solver 合約中的。具體的流程如下:
在Solver 合約中的solve() 函數中,呼叫Bank 合約的WITHDRAW() 函數發起提款操作。
在WITHDRAW() 函數中,先執行CHECKVALUE() 函數。由於我們的callvalue 是5 wei(小於0x 10),所以會跳到over 標籤。
在over 標籤中,會將callvalue * 2 (即10 wei)傳送給呼叫者(也就是Solver 合約)。由於在Solver 合約的fallback 函數中,如果接收到的Ether 數量等於10 wei,那麼就會回滾交易,所以over 標籤中的call 操作會失敗,CHECKVALUE() 函數傳回0 。
WITHDRAW() 函數繼續執行,將Bank 合約的全部餘額傳送給呼叫者(也就是Solver 合約)。這是透過selfbalance caller gas call 這行程式碼實現的,其中selfbalance 是合約的餘額,caller 是呼叫者的地址,gas call 是發起呼叫的操作。
如果這個call 操作成功,那麼Bank 合約的全部餘額就會被送到Solver 合約。如果這個操作失敗,那麼就會直接跳到noauth 標籤,執行revert 操作回溯交易。

3. 100%
這個挑戰的目標是讓SPLIT 和_splitsById[ 0 ].wallet 的ETH 餘額都必須為0 。漏洞存在於distribute() 函數中,該函數僅透過比較abi.encodePacked 結果的雜湊來驗證參數,但由於accounts 和percents 是動態類型,因此可以在分配過程中進行調整。我們的解決方案是透過操縱accounts 和percents 數組,利用distribute() 函數的參數驗證不足,提取出比存入更多的ETH。
漏洞分析
Split 合約的distribute() 函數可以用來根據建立SplitWallet 時指定的帳戶和百分比來分配特定資產。分配後,使用者可以根據balances 中儲存的值進行提現。然而, distribute() 函數有參數驗證不足的問題。此函數僅透過比較abi.encodePacked 結果的雜湊來驗證參數,而accounts 和percents 是動態類型。因此,在分配過程中,我們可以稍微調整accounts 和percents。

在創建SplitWallet{id: 0 } 時,第一個索引的帳戶被意外地留空了。

所以我們可以使用修改過的accounts 和percents 從SplitWallet{id: 0 } 提取所有ETH,但不分配給任何人,同時保持哈希不變(注意數組元素被填充到32 字節)。

類似地,我們可以利用abi.encodePacked 引起的哈希碰撞,提取比存入的ETH 更多,以排空Split。
解決方案
我們主要編寫了solve 函數來排空SPLIT 和_splitsById[ 0 ].wallet 的ETH 餘額。整個解決方案的關鍵在於透過操縱accounts 和percents 數組,以及利用distribute 函數的行為,在不違反哈希校驗機制的情況下,提取出更多的ETH。具體思路如下:
通過調整accounts 和percents 數組,可以控制ETH 的分配。這裡使用了一個只有一個地址(即我們的地址)的accounts 陣列和一個包含兩個元素的percents 陣列。
利用split.distribute 函數,將ETH 從SplitWallet 提取到我們的帳戶。這一步是透過在distribute 函數中適當調整參數來實現的,以確保我們可以接收到ETH。
接下來,建立一個Split 實例,並設定我們的地址為接收者。
透過split.deposit 函數存入一定量的ETH,然後再次利用split.distribute 函數來提取更多的ETH。
最後,呼叫split.withdraw 函數,從Split 合約中提取所有ETH,完成挑戰。

4. Dai++
這個挑戰的目標是讓Stablecoin 的總供應量超過10 ^ 12* 10 ^ 18 。漏洞在於AccountManager 合約使用ClonesWithImmutableArgs 建立新帳戶時,不變參數的長度限制被忽略了,導致當參數長度超過65535 字節時,可能部署一個損壞的合約。我們的解決方案是創建一個包含過長參數的帳戶,使increaseDebt() 函數變成一個幻影函數(phantom function),從而繞過健康檢查,允許鑄造大量穩定幣而不增加債務。
漏洞分析
被SystemConfiguration 合約授權的帳戶才可以鑄造穩定幣。只有SystemConfiguration 的擁有者才能更新系統合約(即授權帳戶),而AccountManager 合約是唯一被授權的合約。
在AccountManager 合約中,只有有效帳戶才能鑄造穩定幣。同時,賬戶上的債務也會增加。

在increaseDebt() 函數中,如果在債務增加後帳戶不健康,則交易將失敗。然而,玩家沒有足夠的ETH 來鑄造10 ^ 12 個穩定幣並保持帳戶健康。

值得注意的是,AccountManager 使用ClonesWithImmutableArgs 建立新帳戶。與帳戶互動時,不變的參數將從calldata 中讀取,以節省gas 成本。但是ClonesWithImmutableArgs 中有一條註解:「@dev 資料不能超過65535 字節,因為2 字節用於儲存資料長度」。
![]()
由於不變的參數儲存在建立的代理合約的代碼區域中,因此在部署期間,代碼大小將根據資料長度計算。然而,應該返回的代碼大小也儲存在2 字節中。因此,如果runSize 超過65535 字節,可能會部署一個損壞的合約。我們可以把increaseDebt() 函數當作幻影函數來忽略這個呼叫。

現有參數長度是20 + 20 + 32 = 72 字節,encoded recoveryAddresses 的長度將是32 字節的倍數。

解決方案
我們編寫了Solve 合約,利用AccountManager 合約的漏洞來鑄造大量的穩定幣。
首先,透過呼叫AccountManager 的openAccount 函數,建立一個包含異常長度參數的新Account。這是透過傳遞一個長度為2044 的空地址數組來實現的。由於參數長度超出預期的65535 字節限制,這導致在內部建立的代理合約損壞。
為了確保參數長度正確,計算公式72 + 2044 * 32 + 2 + 0x 43 - 11 = 65538 被使用。這裡72 是現有參數長度, 2044 * 32 是recoveryAddresses 編碼後的長度, 2 是儲存資料長度的字節數,0x 43 是建立階段字節碼長度, 11 是執行時間合約建立時的字節碼長度。計算結果65538 超出了最大長度65535 ,因此部署時會建立一個損壞的合約。
使用新創建的損壞的Account 通過mintStablecoins 函數鑄造大量穩定幣。由於賬戶合約損壞,increaseDebt 函數(應增加帳戶債務)實際上不會被正確執行,從而允許鑄造穩定幣而不增加任何債務。

5. DoDont
這個挑戰的目標是竊取DVM(代理投票機制)專案中的所有WETH。漏洞位於DVM.sol 的init 函數中,該函數缺乏呼叫限制,允許任何人更改BASE_TOKEN 和QUOTE_TOKEN 地址。我們的解決方案利用了這個漏洞,透過在閃電貸過程中更改這些地址為我們控制的代幣合約,繞過閃電貸機制的餘額檢查。
漏洞分析
在快速審查這個DVM 專案後,我們注意到DVM.sol 中的init 函數缺乏任何呼叫限制。這是問題的根本原因。

我們可以隨時呼叫init() 函數來更改BASE_TOKEN 和QUOTE_TOKEN,這些是挑戰中閃電貸的基礎代幣地址。在閃電貸中利用這樣的漏洞很容易,因為我們只需要在閃電貸過程中將BASE_TOKEN 和QUOTE_TOKEN 更改為他們控制的代幣合約地址。這允許他們在閃電貸期間控制餘額,繞過閃電貸機制中的餘額檢定。

解決方案
我們創建了Solve 合約,用於與挑戰合同互動。建立一個Exploit 合約用於執行攻擊,該合約首先利用flashLoan 函數取得WETH 餘額,然後透過DVMFlashLoanCall 函數呼叫init,更改BASE_TOKEN 和QUOTE_TOKEN 的位址為控制的代幣合約。通過這種方式,我們可以繞過閃電貸機制的餘額檢查,並最終竊取DVM 中的所有WETH。

6.Grains of Sand
這個挑戰的目標是將代幣商店中的GoldReserve(XGR)餘額減少至少11111 × 10 ^ 8 。漏洞在於GoldReserve 代幣在轉移時會收取費用,但代幣商店不支援有轉移費用的代幣。我們的解決方案是透過重複存入和提取GoldReserve 代幣(這兩個操作會有轉移費用)來排空商店中的代幣。
漏洞分析
這個挑戰所在的私鍊是從以太坊主網的18437825 號區塊分叉而來的。

GoldReserve(XGR)代幣在轉移時會收取費用,但代幣商店不支援有轉移費用的代幣。因此,我們可以透過重複存入和提取來從商店中排空代幣。

現在我們要先拿到一些GoldReserve 代幣!通過trade() 函數,我們可以用簽名來換取$XGR。

交易訂單可以部分成交。通過Dune,我們可以找到未過期的GoldReserve 代幣訂單。幸運的是,有兩個訂單有大量未售出的代幣。

解決方案
我們創建了Solve 合約,用於與挑戰合同互動。首先,透過trade() 函數交易來獲得一些GoldReserve 代幣。然後利用代幣商店中的存入和提取機制,反復進行操作來減少代幣商店的代幣餘額。通過這種方式,可以成功地從代幣商店中排空GoldReserve 代幣,滿足挑戰的條件。

7.Suspicious Charity
這個挑戰的目標是操控Python 腳本中的價格緩存,以影響代幣的價格和流動性計算。挑戰中的漏洞源自Python 腳本根據名稱緩存池中的代幣地址,這些名稱使用string(uint 8) 建構時,超過0x 80 的值在Python 中會變得相同,導致錯誤的緩存。我們的解決方案是透過建立兩個交易對:一個是高價格低流動性的交易對,用於更新緩存中的tokenPrice;另一個是低價格高流動性的交易對,在「同名池」中更新tokenAmount。透過這種方法,利用Python 腳本中的錯誤計算,我們成功操控了代幣價格和流動性,最終實現竊取DVM 中的所有WETH 的目標。
漏洞分析
這個問題源自於Python 腳本根據名稱緩存池中的代幣位址,這些名稱是使用string(uint 8) 建構的。我們注意到,當值超過0x 80 時,它們在Python 腳本中變得相同,這可能導致錯誤的緩存。在Python 腳本的get_pair_prices 函數中,這會導致錯誤的價格計算。

我們先建立78 個無用的交易對,然後建立兩個可操控的交易對,發動攻擊。

第一個交易對,其特點是價格高、流動性低,更新緩存中的tokenPrice。隨後,第二個價格低、流動性高的交易對在「同名池」中更新tokenAmount。由於守護進程繼續運行,它累積的捐贈值達到了相當高的數字。

解決方案
建立Exploit 合約,用於完成挑戰。該合約首先創建一些無用的代幣交易對,然後創建一個高價格低流動性交易對和一個低價格高流動性交易對。通過這種方式,可以操控Python 腳本中的價格緩存,使得在特定條件下,代幣價格和流動性的計算出現錯誤。完成挑戰後,將累積的價值轉移到指定的地址。

8.Token Locker
這個挑戰的目標是利用UNCX_ProofOfReservesV2_UniV3 合約的漏洞來竊取該合約中的NFT。漏洞在於lock() 函數允許使用者將流動性鎖定在合約中,但該函數接收的LockParams 結構體中的nftPositionManager 參數可以被替換為惡意合約。這允許我們透過自訂的NFT 位置管理器控制NFT 的位置和流動性。我們的解決方案是建立一個TokenLockerExploit 合約,它透過操作UNCX_ProofOfReservesV2_UniV3 合約中的lock 函數,以及使用CustomNftPositionManager 合約來操縱NFT 的位置和流動性。這樣,我們就可以轉移並控制NFT 合約中的資產,最終成功排空合約中的資金。
漏洞分析
這個問題源自於合約UNCX_ProofOfReservesV2_UniV3,它其實是以太坊主網上0x7f5C649856F900d15C83741f45AE46f5C6858234 合約的分叉。在快速審查代碼後,我們需要更仔細地查看使用者可以與之互動的外部函數,尤其是lock() 函數。
在UNCX_ProofOfReservesV2_UniV3 合約中,lock() 函數允許使用者透過將流動性鎖定在合約中來保護他們的流動性。這個函數提供了兩個選擇:使用者可以將NFT 轉換為全範圍並索取相關費用,這些費用隨後將返還給申請者,或者他們可以利用已經存在的位置。

這個函數接收結構體LockParams 作為輸入參數,特別是nftPositionManager。

INonfungiblePositionManager nftPositionManager 的可用性意味著我們可以輸入我們的合約,這將隨後傳回來自UNCX_ProofOfReservesV2_UniV3 需要排空合約的外部呼叫。
在lock() 函數執行期間,可能會呼叫_convertPositionToFullRange() 函數。下面突出顯示的是薄弱點。

我們只需傳遞如下參數:
mintParams.token 0 // nftPositionManager 傳回真正的Uniswap 位置管理員的地址
address(_nftPositionManager) // 自訂nftPositionManager 的地址
mintParams.amount 1 Desired // 我們應該傳遞我們想要排空的NFT ID。
因為ERC 721 和ERC 20 有相同的transfer() 函數,所以在_convertPositionToFullRange() 函數中的如下表達式導致將自己的NFT 轉移到惡意nftPositionManager:

解決方案
我們建立了TokenLockerExploit 合約,用於竊取NFT。該合約透過操控UNCX_ProofOfReservesV2_UniV3 合約中的lock() 函數,以及透過CustomNftPositionManager 合約來操縱NFT 的位置和流動性,實現對合約資金的排空。

9. Skill Based Game
這個挑戰的目標是透過連續贏得BlackJack 遊戲來耗盡0xA65D59708838581520511d98fB8b5d1F76A96cad 以太坊主網上的所有資金。挑戰的漏洞在於BlackJack 遊戲合約的發牌函數(Deck.deal())依賴區塊屬性(如block.number 和block.timestamp)來模擬隨機性,這可能使得結果被預測。我們的解決方案是建立一個Attacker 合約來模擬發牌過程,並根據預測的結果決定是否進行實際的下注。
漏洞分析
為了完成這個挑戰,我們需要事先知道將要抽取的牌,這樣才能做出明智的決策來決定要玩哪些遊戲。現在,讓我們深入了解合約如何管理發牌。玩家需要呼叫deal() 函數,而最後必須觸發checkGameResult():

發牌過程在Deck.deal() 函數內處理。這種生成隨機性的方法依賴於區塊屬性和某些變量,正如下面的程式碼片段所證明的。這種實現引入了一個漏洞,允許結果被預測。

發牌過程涉及計算blockhash、玩家地址、已發牌數和block.timestamp 的哈希。這是一種眾所周知的模仿隨機性的方法,可以簡單地通過等待所需的區塊,根據新數據重新計算遊戲結果,如果遊戲結果符合我們的要求,那麼我們就必須玩。
解決方案
我們建立了一個使用Deck 函式庫的Attacker 合約來執行攻擊。合約首先模擬發牌過程,然後基於預測的結果決定是否進行實際的下注。

此時,我們只需重複執行這個合約中的play() 函數,並用5 ether 作為值,直到BLACKJACK 合約中的資金耗盡。以下是實現此目的的腳本:

10. Enterprise Blockchain
這個挑戰的目標是從L1 鏈上的l1 Bridge 中提取至少10 個FlagTokens。挑戰的漏洞在於L2 節點在處理特定的ADMIN 預編譯合約呼叫時可能會崩潰,導致L2 節點重新啟動並從先前的狀態載入。我們的解決方案是利用這個漏洞,先在L2 向L1 發送遠端訊息將FlagTokens 轉移到L1,然後觸發L2 節點崩潰和重新啟動。通過這種方式,即使L2 節點的狀態恢復到轉帳發生前,資金已經成功轉移到L1,而L2 上的資金並未減少,從而實現了挑戰的目標。
漏洞分析
這裡有兩條鏈。
(1)挑戰合約部署在L1。最初,l1 Bridge 中有100 個FlagTokens(18 個小數)。

用戶可以通過橋來在鏈之間轉移資金。中繼器將監聽兩個鏈上的SendRemoteMessage 事件,並將訊息轉發到目標鏈。


為了發出SendRemoteMessage 事件,我們可以呼叫sendRemoteMessage() 函數,而在另一條鏈上要執行的交易可以自訂。

由於也提供了L2 RPC,並且玩家擁有一些以太幣,我們可以從L2 向L1 發送遠端訊息,並將代幣從l1 Bridge 轉移到用戶。

但是,the sendRemoteMessage() 函數並非打算公開使用,它預期只能透過ethOut() / ERC 20 Out() 在鏈間轉移資金。
( 2) L2 鏈上部署了一個SimpleMultiSigGov 合約,位於地址0x 31337 。它可以用來與預編譯合約ADMIN 互動。

ADMIN 的預編譯合約有一個fn_dump_state() 函數,其中的操作可能導致未定義行為。首先,x.len() 應該大於0x 10 ,否則當i == x.len() 時程式將因索引越界而發生panic。 states 是指向切片[u 8 ] 的原始指針,切片在x 86-64 上為16 字節。 states.offset 的計數單位是切片。由於i 的最大值是0x 10 ,所以應該分配的最小內存是0x 110 (16 * (0x 10 + 1))而不是0x 100 。因此,如果x.len() 大於0x 10 ,程式將寫入未指派的記憶體states.offset(0x 10)。

呼叫fn_dump_state() 時,如果x.len() > 0x 10 ,將會導致L2 節點崩潰。anvil 服務將很快重啟並從先前轉儲的狀態中載入狀態。
狀態轉儲間隔為5 秒,但只要中繼器捕捉到SendRemoteMessage 事件,它就會轉發訊息。如果L2 節點在新的跨鏈轉帳交易被包含在區塊中但最新狀態尚未轉儲時崩潰,那麼訊息將被轉發到L1,而L2 的狀態只能恢復到轉帳發生前的狀態。在這種情況下,用戶可以將資金轉移到L1,而在L2 中不花費任何資金。

只有位於0x 31337 的SimpleMultiSigGov 可以與ADMIN 互動,但我們無法取得任何有效簽名來執行交易。另外,我們可以利用狀態覆蓋集來暫時覆蓋0x 31337 處的代碼,並模擬呼叫。
ADMIN 的admin_func_run() 函數是入口點。要呼叫fn_dump_state() 函數,前兩個字節應該是0x 0204 。

解決方案
使用pwn 和其他工具,我們可以執行一系列操作來觸發L2 節點崩潰,然後在L2 節點重新啟動並從先前的狀態加載時,執行跨鏈轉移。通過這種方式,我們可以在不實際花費L2 上任何資金的情況下,將資金轉移到L1。這個過程需要精確的時序控制和對L2 節點狀態的操作。


11. Dragon Tyrant
這個挑戰是一個與龍對抗的遊戲,打敗龍即可取得勝利,完成挑戰。這個挑戰的關鍵漏洞有兩個:
( 1) 可預測的隨機數:遊戲的隨機數產生過程可以被預測,而這個隨機數決定了攻擊/ 防禦決策,進而影響遊戲勝負。遊戲的隨機數產生器依賴可預測的種子,這些種子可以透過監控特定的區塊鏈交易(resolveRandomness)被提前取得。我們的解決方案是先透過交易池監聽器監控並收集足夠的種子信息,然後利用這些信息預測下一個種子。
( 2) 邏輯漏洞:玩家只有裝備上了傳奇劍和盾牌,才會最大化自己的攻擊值和防禦力。遊戲合約允許玩家傳入自己的商店合約地址來購買裝備,而驗證這個自訂商店是否合法的機制是基於比較商店合約的codehash 而不是其地址。這意味著,如果玩家能夠創建一個具有與官方商店相同codehash 但不同構造函數的合約,他們就可以繞過正常的購買流程和價格限制。我們的解決方案利用了這個漏洞,透過創建一個自訂的商店合約來購買傳奇劍和盾牌,來繞過高昂的購買成本。
遊戲背景
這個挑戰背景是一個小遊戲。遊戲中有一條龍,具有超強的力量/ 體質(高攻擊力/ 防禦力)和60 點生命值.而你作為,主角,擁有隨機產生的較弱屬性和1 點生命值,需要擊敗這條龍。你和龍都是ERC 721 代幣,當龍在戰鬥中失敗並隨後被銷毀時(解決方案檢查),挑戰即為成功。
你必須發動戰鬥,戰鬥最多有256 個小回合。在每個回合中,你和龍都可以選擇攻擊或防禦。傷害計算總結如下:

每個小回合後,雙方的生命值都會減去對應的傷害。一方生命值達到0 時,它就會被銷毀,遊戲結束。如果雙方生命值都達到0 ,發動攻擊的一方——玩家——將被銷毀。
龍和玩家的攻擊/ 防禦屬性是基於各自的屬性和裝備計算的。每方可以裝備一件武器和一件盾牌。商店中有一些裝備出售,包括一把非常強大的劍。龍不會裝備任何東西,玩家初始擁有1000 ETH。
遊戲中有兩個地方使用了隨機數產生器。一個用於確定玩家的屬性,另一個用於確定龍的攻擊/ 防禦決策。使用基於ECC 的隨機數產生器,種子由連結下提供。
攻擊/ 防禦決策
要擊敗龍,我們需要將它的生命值降到0 ,同時保持我們唯一的生命值。查看攻擊/ 防禦矩陣,這意味著我們不能讓攻擊/ 攻擊場景發生。在攻擊/ 攻擊回合中,雙方的生命值都會降至0 ,導致玩家失敗。這是因為雙方的攻擊屬性都遠高於對方的生命值。由於防禦- 防禦回合類似於NOP,我們只能依靠攻擊/ 防禦回合和防禦/ 攻擊回合。

由於雙方的攻擊/ 防禦決策都是提前提交的,我們需要事先知道龍的選擇,以避免攻擊/ 攻擊回合。這需要我們預測隨機數生成,進而需要我們預測隨機種子。幸運的是,有一個Python 函式庫可以預測Python 的random 模組的輸出,一旦它觀察到來自生成器的大約20 k 位的輸出。
那我們要如何為這個函式庫提供我們無法存取的Python 隨機模組的20 k 位輸出呢?事實證明,我們可以鑄造任意數量的玩家,每個鑄造交易都會觸發鏈下種子提供者提交一個隨機種子。我們可以在待處理的交易池中監控這些交易,從而捕捉種子。事實上,我們發現在捕獲了78 個鑄造的種子後,我們可以預測隨機種子:

由於ECC 隨機數產生器是確定性的,我們可以預測龍的攻擊/ 防禦決策。我們總是做與龍相反的事。如果龍攻擊,我們就防禦。如果龍防禦,我們就會攻擊。
攻擊/ 防禦屬性
沒有任何裝備,我們的適度屬性將導致我們在戰鬥中失敗。龍擁有type(uint 40).max 的攻擊屬性和type(uint 40).max - 1 的防禦屬性。沒有任何裝備,當我們攻擊而龍防禦時,我們不會對龍造成任何傷害。當龍攻擊而我們防禦時,我們會立即失敗。
自然而然地,我們將目光投向了傳奇劍。擁有這把劍後,我們的攻擊屬性將達到type(uint 40).max,使我們在攻擊並且龍防禦時能對龍造成1 點HP 傷害。如果我們重複這個過程60 次,龍就會死亡。這就有了希望。
我們怎麼能負擔得起這把劍,它的價格是100 萬ETH,而我們只有1000 ETH?事實證明,當我們裝備這把劍時,遊戲允許我們自己傳入商店合約,並且只要商店合約之前已被工廠合約的所有者批准,遊戲就會愉快地繼續。仔細檢查後發現,這種檢查並不是透過驗證商店合約地址來完成的,而是透過比較商店合約的codehash來完成的。這意味著,只要我們傳入一個具有相同codehash 的商店合約,我們就能繼續。因為extcodehash 不包括建構函數,我們可以創建一個具有相同代碼但不同構造函數的自己的物品商店,並使用它來為我們的玩家裝備劍。
這個方法有效。使用以下構造函數的假商店,我們可以獲得傳奇劍以及新的傳奇盾牌:

擁有這兩件傳奇裝備,我們將實施type(uint 40).max 的攻擊屬性和type(uint 40).max 的防禦屬性。當龍攻擊時,我們不會失去任何HP,並且當我們攻擊時,我們會對龍造成1 點HP 傷害。
解決方案
以下是解決方案的逐步過程:
將玩家代幣鑄造到我們自己的錢包。
部署假商店,並使用它為玩家裝備兩件傳奇裝備。
部署攻擊者合約,如挑戰所要求的。合約將接管玩家代幣,發動戰鬥,並提供玩家的攻擊/ 防禦決策。
將玩家代幣轉移到攻擊者合約。
啟動待處理交易池監聽器,監控resolveRandomness 交易。它會捕獲種子,並在收集到足夠的資訊後預測下一個種子。
鑄造78 個額外的玩家代幣。
此時,池監聽器應該已經收集到足夠的資訊以預測下一個種子。
將預測的種子輸入隨機數產生器,以決定龍的攻擊/ 防禦決策。
將龍的決策字串位元反轉,以推導出玩家的決策。當查詢時,攻擊者合約將提供玩家的決策。
攻擊者合約發動攻擊,導致龍的失敗。
12. Hopping Into Place
這個挑戰的目標是從一個跨鏈橋合約中提取所有資金。漏洞存在於_additionalDebit() 函數中,這個函數在計算保證人(bonder)的責任時,如果challengePeriod 被設定為0 ,則不會增加任何債務。我們的解決方案是利用這個漏洞,透過設定challengePeriod 為0 ,使得numTimeSlots 也為0 ,從而阻止增加債務。接著,我們使用bondTransferRoot() 函數來提取任意數量的代幣,因為getDebitAndAdditionalDebit() 函數在這種情況下失去了原本的功能,導致債務不會增加。通過這種方式,我們成功地排空了跨鏈橋中的資金。
漏洞分析
在這個挑戰中,我們的身分是治理者,所以我們可以改變跨鏈橋的一些配置。
問題的根源在於_additionalDebit() 函數,我們注意到債務是在if 語句中加入的。這表示如果numTimeSlots 等於0 ,則不執行該語句。保證人(bonder)的責任並沒有增加。顯然,這種設計是不合理的;在任何情況下都不應跳過債務的增加。
我們可以利用這一點,透過將challengePeriod 設為0 ,從而實現numTimeSlots 為0 的條件。

這樣,getDebitAndAdditionalDebit 函數就失去了其額外的功能,無論我們怎麼操作,債務都不會增加。

這也影響了requirePositiveBalance 修飾符,該修飾符要求在函數執行後,我們的信用必須大於增加的債務。然而,由於該函數失去了其額外的功能,我們的債務保持不變。這意味著我們可以使用此修飾符修改的函數來排空跨鏈橋。

最後,讓我們來看看bondTransferRoot 中的邏輯。這個函數將totalAmount 設置為呼叫者的債務,並將totalAmount 加到transferRoots 以供提取。因此,我們可以使用這個函數來提取任意數量的代幣。

解決方案
我們編寫了幾個關鍵合約,透過將挑戰期限設為0 來阻止增加附加債務,從而提取跨鏈橋中的資金。每個合約實現了特定的功能:
Exploit 合約:這是攻擊的主要合約,負責執行整個攻擊流程。它首先與挑戰合約Challenge 關聯,然後通過一系列操作來操縱跨鏈橋IBridge,最終達到提取資金的目的。
MockMessageWapper 合約:這個合約模擬了跨鏈訊息傳遞的過程。在實際應用中,它並沒有執行任何有效操作,而是起到佔位的作用,讓Exploit 合約模擬跨鏈互動過程。
Solve 合約:此合約繼承自CTFSolver,用於在Capture The Flag (CTF) 挑戰中與挑戰合約Challenge 進行互動。它主要負責呼叫Exploit 合約的exploit 方法來執行攻擊,並在攻擊成功後確認挑戰是否已解決。
IBridge 介面:這是一個定義了跨鏈橋合約方法的介面。它包含了Exploit 合約中所用到的跨鏈橋操作方法,例如新增保證人、設定挑戰期限、綁定轉移根、提取資金等。
IChallenge 介面:這個介面定義了挑戰合約Challenge 中的方法,允許Exploit 合約存取挑戰中的跨鏈橋地址。


13. Oven
這個挑戰的目標是恢復一個隱藏的FLAG 值。挑戰的核心是fiat_shamir() 函數,這個函數使用了自訂的雜湊函數custom_hash() 來產生隨機數,然後使用這個數參與計算。關鍵的漏洞位於fiat_shamir() 函數中,特別是在r=(v - c * FLAG) mod (p-1) 這個表達式中,它涉及已知的r, c, p 值和未知的FLAG 值。解決方案是將這個問題轉換為一個格問題,然後使用格基約減算法(LLL 算法)來找出FLAG 值。
漏洞分析
程式碼功能:使用者可以取得FLAG 的隨機簽名,產生隨機簽名的邏輯位於fiat_shamir() 函數中。使用了自訂的雜湊函數custom_hash 來產生哈希值,該函數呼叫了四種不同的散列算法,因此目前無法破解其隨機性。

此外,fiat_shamir 變換是密碼學中非常重要的工具,其核心在於使用散列演算法產生隨機數,為加密協定增加隨機性。 FS 變換的一個典型應用是將非互動性引入零知識證明系統中,然後建構諸如snark 和stark 等協定。
從原始碼中,我們可以取得t, r, p, g, y 等信息,但實際上c 可以使用custom_hash() 函數計算。因此,漏洞集中在fiat_shamir() 函數中,它是對FLAG 進行簽名的功能部分,重點在於:r=(v - c * FLAG) mod (p-1)。對於這個等式,我們目前可以得到的資訊是r, c, p 都是已知值,且FLAG 的數字已確定:assert FLAG.bit_length()<384 。 它可以與1996 年Dan Boneh 提出的HNP 問題(帶有可變模數)聯繫起來,並且可以使用標準的格算法進行攻擊。有關基於格攻擊的更詳細的密碼分析,請參考相關論文。
問題在於代碼中的r=(v - c * FLAG) mod (p-1)。由於r, c, p 都是已知值,那麼:
首先,在數學上轉換上述等式:r-v+c*FLAG= 0 mod (p-1),其中只有v 和FLAG 是未知數。
其次,建構格:
,其中K 是FLAG 的上界,所有空白處都是0 。根據Babai 的CVP 解算法,必定存在解向量j=[l1, l2, l3, FLAG, 1 ],使得jM=jk 成立。
注意jk 是格中的短向量,因此我們可以使用LLL 演算法在多項式時間內找到這個短向量。請注意,短向量的每個元素可以用64 位元表示,因此確定了上界K= 2 ^ 64 。
技巧: 這裡是關於數據量的問題的說明。我們怎麼知道恢復FLAG 需要多少組資料?這需要使用高斯啟發式來估計最短向量長度,而所需的目標向量範數小於這個長度。但是,由於這是CTF 比賽的背景,通常可以先使用三到四或五組數據。如果不行,可以使用上述方法精確計算。這裡,我們收集了五組資料以備用,但實際上只用了三組資料就解出了FLAG。
解決方案
我們的程式碼需要在sage-python 環境中運作。主要思路如下:
建構格:首先建立一個特定的格,其中包含已知的p, c, r 值以及未知的FLAG 值。這個格是由上述等式轉化而來的。
使用LLL 算法:應用LLL 算法來尋找格中的短向量。 LLL 算法是一種有效的算法,可以在多項式時間內找到格的一個基礎向量,這個向量在數學上與原始問題的解相關。
恢復FLAG:一旦找到短向量,就可以從中提取FLAG 的值。由於短向量中的元素可以用64 位元表示,這就為FLAG 的大小設定了一個上限。

從競賽到實踐
Salute 團隊在Paradigm CTF 2023 比賽中累積了寶貴的經驗,這些經驗現在是Salus 安全公司提供的增強型智慧合約審計服務的重要組成部分。如果您需要頂級的智慧合約審計服務,請隨時聯絡我們。我們致力於為您的需求提供全面和高效的支援。


