🎯 什麼情境該想到我

當你「要把一份會管錢的合約丟上鏈,想先照著一份清單把已知的坑掃過一遍」的時候。

這張是 工具-智能合約 的安全展開:那張講「合約是什麼、為什麼不可變」,這張講「不可變 + 管錢 + 全公開,會被人怎麼玩死」。

書中 Ch.9 的結構固定是三段——vulnerability(漏洞怎麼成立)→ attack example(怎麼打)→ preventative techniques(怎麼防),讀的時候照這個節奏走。

為什麼合約安全特別難

Ch.9 章首(PDF p.328)給出四個理由:

“As with other programs, a smart contract will execute exactly what is written, which is not always what the programmer intended. Furthermore, all smart contracts are public, and any user can interact with them simply by creating a transaction. Any vulnerability can be exploited, and losses are almost always impossible to recover.”
—— 譯文:跟其他程式一樣,智能合約會完全照著寫的執行,而那不見得是寫的人想要的。此外,所有智能合約都是公開的,任何使用者只要發一筆交易就能與之互動。任何漏洞都會被利用,而損失幾乎總是無法追回

四點連起來:照字面執行 → 全公開 → 誰都能互動 → 出事救不回來。這就是為什麼合約要用「航太工程等級」的嚴謹度寫。

⚙️ 怎麼用

第一層:防禦性設計五原則(Ch.9 “Security Best Practices”, PDF p.329–330)

書把 defensive programming 拆成五條,寫合約前先當檢查表:

  1. Minimalism/simplicity(極簡)

    “Complexity is the enemy of security. The simpler the code, and the less it does, the lower the chances are of a bug or unforeseen effect occurring.”
    —— 譯文:複雜度是安全的敵人。程式越簡單、做的事越少,出現 bug 或非預期效果的機會就越低。

    書講得很直白:如果有人跟你說他們的合約寫了「好幾千行程式碼」,你就該懷疑那個專案的安全性。Simpler is more secure.

  2. Code reuse(重用)——別重造輪子,遵守 DRY(見 工具-DRY原則)。書特別點名 “Not Invented Here” syndrome:想自己重寫一遍來「改進」某個元件時,安全風險通常大於改進的價值。

  3. Code quality(程式品質)

    “Writing DApps in Solidity is not like creating a web widget in JavaScript. Rather, you should apply rigorous engineering and software development methodologies, as you would in aerospace engineering or any similarly unforgiving discipline.”
    —— 譯文:用 Solidity 寫 DApp 不像用 JavaScript 做一個網頁小工具。你應該套用嚴謹的工程與軟體開發方法論,就像做航太工程或任何同樣不容出錯的領域那樣。

  4. Readability/auditability(可讀 / 可稽核)——合約是公開的,任何人都能讀 bytecode、也能反編譯。既然躲不掉,就乾脆公開開發、用開源協作的方式借社群的集體智慧。

  5. Test coverage(測試覆蓋)——合約跑在公開執行環境,任何人可以餵任何輸入。

    “You should never assume that input, such as function arguments, is well formed, properly bounded, or has a benign purpose.”
    —— 譯文:你絕不該假設輸入(例如函式參數)格式良好、範圍正確、或抱著善意。

第二層:七個著墨最深的反模式

1. Reentrancy 重入(Ch.9 “Reentrancy”, PDF p.332–339)

漏洞:合約把 ether 送到一個未知位址時,對方的 fallback function 會被觸發,而那段惡意程式碼可以再呼叫回原合約——路徑「reenter(重入)」了。

書的 EtherStore 範例中,withdrawFunds 先送錢(第 17 行 msg.sender.call.value(...))、之後才扣 balances(第 18 行)。攻擊合約在 fallback 裡再呼叫一次 withdrawFunds,此時餘額還沒被扣,所有 require 都照樣通過,於是遞迴領錢,一筆交易內把金庫領到只剩 1 ether

防法(書給三招,用一招即可,範例是為了示範才三招全上):

  • 用內建的 transfer:只給 2300 gas,不夠對方再去呼叫另一個合約。
  • checks-effects-interactions pattern:所有改 state 的邏輯都放在送錢/外部呼叫之前;對未知位址的外部呼叫要是該段程式的最後一個動作
  • mutex:用一個 state variable 在執行期間鎖住合約,擋掉重入呼叫。

真實案例:The DAO

“The DAO (Decentralized Autonomous Organization) attack was one of the major hacks that occurred in the early development of Ethereum. At the time, the contract held over $150 million. Reentrancy played a major role in the attack, which ultimately led to the hard fork that created Ethereum Classic (ETC).”
—— 譯文:DAO(去中心化自治組織)攻擊是以太坊早期發展中的重大駭客事件之一。當時該合約持有超過 1.5 億美元。重入在這次攻擊中扮演了主要角色,最終導致了那次催生出 Ethereum Classic(ETC)的硬分叉。

2. Arithmetic Over/Underflows 算術溢位(PDF p.340–346)

漏洞:EVM 用定長整數型別

“The Ethereum Virtual Machine specifies fixed-size data types for integers. This means that an integer variable can represent only a certain range of numbers. A uint8, for example, can only store numbers in the range [0,255]. Trying to store 256 into a uint8 will result in 0.”
—— 譯文:EVM 為整數指定了固定大小的資料型別。這表示一個整數變數只能表示某個範圍的數字。例如 uint8 只能存 [0,255] 範圍內的數;試圖把 256 存進 uint8 會得到 0。

把定長變數想成環狀(書用里程表和 sin 加 2π 當比喻):uint8 的 0 減 1 會變成 255(underflow);加上 2^8 = 256 則原地不動。

書的兩個範例:

  • TimeLock(時間鎖金庫):攻擊者拿到私鑰後,讀出公開的 lockTime,呼叫 increaseLockTime(2^256 - userLockTime) 讓它溢位歸零,直接把鎖打開領走 100 ether。
  • Ethernaut 的 Tokenrequire(balances[msg.sender] - _value >= 0) 這行永遠成立——uint256 減出來的結果不可能為負,只會 underflow 成巨大正數。零餘額的人可以憑空 transfer 出代幣。

防法:用數學函式庫取代標準的 + - * 運算子(除法排除,因為不會溢位、且 EVM 除以 0 會 revert)。書明確推薦 OpenZeppelin 的 SafeMath,並示範改寫成 balances[msg.sender].add(msg.value) 的版本。

真實案例:PoWHC(Proof of Weak Hands Coin)被 underflow 搬走 866 ether;一批 ERC20 合約的 batchTransfer() 溢位漏洞(CVE-2018-10299)。

3. Unexpected Ether 非預期的 ether(PDF p.347–354)

漏洞:很多人以為合約只能透過 payable 函式收 ether。錯。有兩種方式能強制把 ether 塞進合約而不執行任何程式碼

  • selfdestruct(自毀):任何合約都能呼叫 selfdestruct(target),把餘額全數送到 target;即使 target 沒有任何 payable 函式,fallback 也不會被呼叫
  • Pre-sent ether(預先送款):合約位址是決定性的——address = sha3(rlp.encode([account_address, transaction_nonce])),所以任何人都能在合約還沒部署前算出位址並先打錢進去,合約一誕生就帶著非零餘額。

因此把「合約餘額」當作 invariant(不變量)來檢查是危險的。

“The smoking gun for this vulnerability is the (incorrect) use of this.balance.”
—— 譯文:這個漏洞的確鑿證據就是(錯誤地)使用 this.balance

書的 EtherGame 範例:玩家每次投 0.5 ether,達到 3 / 5 / 10 ether 里程碑時給獎。攻擊者只要 selfdestruct 硬塞 0.1 ether,餘額就永遠不會是 0.5 的倍數,所有里程碑條件永遠不成立;更狠的是塞到超過 finalMileStoneclaimRewardrequire 永遠 revert,獎金永久鎖死

防法:合約邏輯不要依賴 this.balance 的精確值。若需要精確追蹤入金,自己宣告一個變數(如 depositedWei)在 payable 函式中累加——強制塞入的 ether 影響不到它。

4. DELEGATECALL(PDF p.355–363)

漏洞DELEGATECALLCALL 幾乎相同,差別在目標位址的程式碼是在呼叫端的 context 中執行msg.sendermsg.value 維持不變。這讓函式庫成為可能,但也代表——

“It is also important to notice that when we say that delegatecall is state-preserving, we are not talking about the variable names of the contract, but rather the actual storage slots to which those names point.”
—— 譯文:還有一點很重要:當我們說 delegatecall 會保留狀態時,指的不是合約的變數名稱,而是那些名稱所指向的實際 storage slot

State variable 是依宣告順序依序放進 slot。書的 Fibonacci 範例中,函式庫的 start 在 slot[0]、calculatedFibNumber 在 slot[1];但呼叫端 FibonacciBalance 的 slot[0] 是 fibonacciLibrary(函式庫位址)。於是函式庫的 setStart(x) 實際上改的是呼叫端的函式庫位址——攻擊者把自己的合約位址轉成 uint 傳進去,就把整個合約劫持了。

防法:用 Solidity 的 library 關鍵字實作函式庫——這保證函式庫是 stateless 且不可自毀的,避開 storage context 的複雜性。使用 DELEGATECALL 時要同時考慮函式庫合約與呼叫合約兩邊的 calling context。

真實案例:Parity Multisig Wallet 第二次駭事件WalletLibrary 本身也是一個合約、有自己的 state。有使用者直接對函式庫合約本身呼叫 initWallet 成為 owner,接著呼叫 kill 讓它自毀。所有指向這個函式庫的 Wallet 合約都沒有更換參照的方法,於是——

“As a result, all ether in all Parity multisig wallets of this type instantly became lost or permanently unrecoverable.”
—— 譯文:結果,所有這類 Parity 多簽錢包裡的 ether 瞬間全部遺失、永久無法取回。

5. Default Visibilities 預設可見性(PDF p.364–368)

漏洞:Solidity 的函式預設是 public。開發者若漏寫可見性修飾詞,本該是 private 的內部函式就對全世界開放。

書的 HashForEther 範例:_sendWinnings()(照命名慣例是內部函式)沒寫可見性 → 預設 public → 任何位址都能直接呼叫它把獎金領走,完全繞過 withdrawWinnings 的中獎條件檢查。

防法永遠明確標註每個函式的可見性,即使它本來就是 public。書提到當時較新版的 solc 已會對沒有明確可見性的函式發出警告。

真實案例:Parity Multisig Wallet 第一次駭事件,約 3100 萬美元的 ether 被偷。initMultiownedinitWallet 兩個函式都沒寫可見性、預設 public,攻擊者直接對已部署的合約呼叫它們,把 owner 重設成自己的位址,然後把錢包提空。

6. Unchecked CALL Return Values 未檢查的呼叫回傳值(PDF p.385–388)

漏洞callsend 失敗時不會 revert,只是回傳 false。開發者若以為失敗會自動 revert 而不去檢查回傳值,state 就會跟事實脫節。

書的 Lotto 範例:winner.send(winAmount); 沒檢查回傳值,下一行就把 payedOut = true。中獎者若因 gas 不足、或本身是個會在 fallback 中 throw 的合約而收款失敗,payedOut 照樣變成 true——接著任何人都能透過 withdrawLeftOver 把獎金領走。

防法:能用 transfer 就用 transfer(外部交易 revert 時它會跟著 revert);非用 send 不可就一定檢查回傳值。更穩健的做法是採用 withdrawal pattern(提領模式):把送錢邏輯隔離成一個獨立的 withdraw 函式,由使用者自己來領,把「交易可能失敗」的負擔轉移到呼叫者身上。

真實案例:Etherpot 樂透(winner.send(subpot) 沒檢查、下一行就標記已付款);更嚴重的版本發生在 King of the Ether。

7. Race Conditions / Front Running 競態與搶跑(PDF p.389–394)

漏洞:交易先進 pool、由礦工挑選並打包,通常按 gasPrice 排序。攻擊者可以盯著交易池,看到有價值的交易後,複製它的資料、用更高的 gasPrice 送出自己的版本,讓自己的交易排在原始交易前面。

書的 FindThisHash 範例:合約放著 1000 ether 懸賞某個雜湊的原像。使用者算出答案 Ethereum! 並送出交易,攻擊者在池中看到、驗證有效、用高得多的 gasPrice 重送——賞金被搶走,真正解題的人一無所獲

“Keep in mind that in this type of ‘front-running’ vulnerability, miners are uniquely incentivized to run the attacks themselves (or can be bribed to run these attacks with extravagant fees). The possibility of the attacker being a miner themselves should not be underestimated.”
—— 譯文:請記住,在這類「搶跑」漏洞中,礦工有獨特的誘因親自執行攻擊(或可用誇張的手續費賄賂他們去執行)。不要低估攻擊者本身就是礦工的可能性。

防法

  • gasPrice上限——只擋得住第一類攻擊者(一般使用者),礦工照樣能任意排序區塊內交易。
  • 更穩健的是 commit–reveal scheme:先送出含隱藏資訊(通常是雜湊)的交易,等它被打包後再送第二筆交易揭露內容。礦工和使用者都無從得知內容,兩類攻擊者都擋得住。但它藏不住交易金額——ENS 的做法是讓 commit 的資料包含願付金額、交易送任意值,reveal 階段再退還差額。

真實案例ERC20 的 approve——Alice 想把 Bob 的額度從 100 改成 50,Bob 盯著鏈、搶先花掉原本的 100,等 Alice 的交易生效後額度變 50,Bob 實際拿到 150。另一例是 Bancor:代幣價格由交易金額決定,任何人都能盯著交易池搶跑套利。

第三層:其餘反模式(書中列名,需要時回去讀)

反模式一句話PDF
Entropy Illusion(熵的幻覺)以太坊是決定性狀態機,鏈上沒有隨機源;用未來/當前的 block 變數當亂數,礦工可操控(挖到不利的區塊就丟掉重挖)。防法:commit–reveal、RandDAO、或中心化的 randomness oracle。p.369–372
External Contract Referencing(外部合約參照)Solidity 中任何位址都能被轉型成任何合約型別,不管那裡的程式碼是不是那個型別。範例:把 Rot13Encryption 換成什麼都不做的 Rot26Encryption 或直接 emit 明文的 Print。防法:用 new 在部署時建立實例、或硬編碼位址、或把位址設成 public 讓人可查。p.373–380
Short Address/Parameter Attack不是打合約,而是打與合約互動的第三方應用。ABI 編碼的參數若短了一個 byte,EVM 會在尾端補零,導致金額被乘以 256。防法:外部應用送上鏈前必須驗證所有輸入參數;參數順序也有影響(補零只發生在尾端)。p.381–384
Denial of Service (DoS)三種樣態:① 迴圈跑過外部可膨脹的陣列(攻擊者狂建帳號讓 for 迴圈 gas 超過區塊上限);② owner operations(特權帳號遺失私鑰 → 整個合約卡死);③ 靠外部呼叫推進狀態(對方合約拒收 ether → 永遠到不了下一個狀態)。防法:改用 withdrawal pattern、owner 改成多簽、加 time-lock 失效保護。p.395–399
Block Timestamp Manipulationblock.timestamp / now 可被礦工小幅調整。書的 Roulette 範例用 now % 15 == 0 判定中獎,池子夠大時礦工有誘因挑一個能中獎的 timestamp。防法:時間戳不可作為亂數來源;需要時間邏輯時改用 block.number 加平均出塊時間估算(BAT 的 ICO 就是這樣做)。p.400–403
Constructors with CareSolidity v0.4.22 之前,建構子是「與合約同名的函式」。合約改名而建構子沒跟著改(或拼錯),它就變成一個任何人都能呼叫的普通函式。真實案例 Rubixi(原名 DynamicPyramid,改名後建構子沒改,任何人都能把自己設成 creator 領走手續費)。已由 v0.4.22 引入的 constructor 關鍵字解決。p.404–407
Uninitialized Storage Pointers未初始化的區域 storage 變數會指向既有的 storage slot。書的 NameRegistrar 範例:函式內的 NameRecord newRecord; 預設落在 storage 且對應 slot[0],而 slot[0] 是 unlocked——傳一個尾端非零的 _name 就能把 unlocked 直接改成 true。防法:對複雜型別明確標註 memorystorage,並正視編譯器的警告。p.408–412
Floating Point and Precision書撰寫當時(v0.4.24)Solidity 不支援定點與浮點數,只能用整數自己刻。除法一律無條件捨去(賣 29 個代幣只拿到 2 ether)。防法:讓分子夠大(用 weiPerTokens 而非 tokensPerEth)、注意運算順序(先乘後除;Solidity 保證照書寫順序運算)、內部一律高精度、輸出時才降精度。可參考 DS-Math。p.413–416
Tx.Origin Authenticationtx.origin最初發起交易的帳號(穿透整個 call stack)。用它做授權會被釣魚:受害者只要送一筆交易給攻擊者的合約,其 fallback 就以受害者為 tx.origin 去呼叫 withdrawAll,錢全被領走。防法:tx.origin 不可用於授權;但 require(tx.origin == msg.sender) 是合法用途(用來擋掉中介合約的呼叫)。p.417–420

第四層:結論——最大化重用可信任的程式碼

“Perhaps the most fundamental software security principle is to maximize reuse of trusted code. In cryptography, this is so important it has been condensed into an adage: ‘Don’t roll your own crypto.’”
—— 譯文:也許最根本的軟體安全原則就是最大化重用可信任的程式碼。在密碼學中這件事重要到被濃縮成一句諺語:「別自己造密碼學。」

書在 “Contract Libraries” 一節推薦 OpenZeppelin suite(ERC20/ERC721 實作、各種 crowdsale 模型、Ownable / Pausable / LimitBalance 等常見行為,其中部分已成事實標準實作)、ZeppelinOS、以及套件管理系統 ethpm

🧪 我實際套用的紀錄

  • (待填)

⚠️ 注意 / 什麼時候不適用

  • ⚠️ 這份清單反映的是 2018 年的 Ethereum / Solidity 生態。 書中自己標明撰寫時的 Solidity 版本是 v0.4.24,所有範例都是那個年代的語法(constructor 之前的同名建構子、sha3throwsuicidevar i)。書出版之後的語言、工具鏈與協議變化一律不在本卡範圍內——要用在今天的專案前,務必回去查現行版本的官方文件與現行的最佳實務。
  • 書中明確提到「已被語言/工具層解掉」的只有這幾項(其餘不要自行推論):
    • Constructors with Care —— 書明說 “This issue has been addressed in version 0.4.22 of the Solidity compiler”,改用 constructor 關鍵字即可。
    • Default Visibilities —— 書說當時較新版的 solc 已會對未標可見性的函式發出警告(只是警告,不是強制)。
    • Uninitialized Storage Pointers —— 書說編譯器會發警告,且當時的 Mist(0.10)已不允許編譯這類合約。
    • 溢位(Arithmetic Over/Underflows):書中給的解法只有「用 SafeMath 這類函式庫」,沒有任何語言層內建檢查。 本書寫於那之前,所以這裡不宣稱任何「編譯器已內建」的說法。
  • 防範措施本身也有代價:書自己在 EtherStore 修正版註明「三招全用是不必要的,我們只是為了示範」——防重入挑一招即可,堆疊防禦會增加複雜度,而 complexity is the enemy of security
  • 「已審計」不等於安全。書在 External Contract Referencing 提醒:安全的合約也可能被用惡意的方式部署——稽核員公開驗證了程式碼,擁有者卻用一個指向惡意合約的位址去部署它,結果是一份「通過公開稽核」但有漏洞的合約。
  • Short Address Attack 不在合約層,是交易所/錢包等第三方應用的輸入驗證問題;只審 Solidity 程式碼掃不到它。
  • DoS 防範的中心化代價:書自己加註,加一個 maintenanceUser 來救 DoS 是可行的,但這類合約通常有信任問題,因為那個角色的權力太大。

🔗 相關工具

  • 工具-智能合約 —— 同一本書;那張講合約的基本模型與不可變性,這張是它的安全展開,先讀那張再讀這張
  • 工具-防禦式編程 —— 出自 程式碼大全;本章的 “Security Best Practices” 就是防禦式編程用在合約上的版本,通用工程原則的源頭在那裡
  • 精通以太坊 —— 本卡來源(Ch.9 “Smart Contract Security”)