委派呼叫注入
委派呼叫注入是一族源於誤用 delegatecall 的漏洞;delegatecall 這個操作碼會在呼叫者自己的儲存與身分中執行另一個合約的程式碼。由於 delegatecall 執行的是目標的位元組碼、讀寫的卻是呼叫者的儲存,並保留原本的 msg.sender 與 msg.value,它威力極大,也極其危險:誰掌控了被呼叫的程式碼、或它所假設的佈局,誰就實質上掌控了呼叫者的狀態。這既是可升級代理背後的引擎,也是以太坊史上一些代價最高失誤的源頭。
兩大危害反覆出現。第一是儲存碰撞:代理與其實作必須對每個儲存槽的含義達成一致,因為 delegatecall 是按槽號、而非按變數名來寫入的。若代理把管理員地址存在第 0 槽,而實作恰好把一個使用者可控的變數放在第 0 槽,一次平凡的使用者寫入就能覆寫管理員。第二是一個未初始化或未受保護、可被 delegatecall 或自毀的實作。2017 年的 Parity 多簽凍結正是如此:共用函式庫被留在未初始化狀態,攻擊者呼叫其初始化成為擁有者,再觸發 selfdestruct,永久報廢了每一個 delegatecall 進它的錢包中約 51.3 萬枚 ETH。
防禦如今已標準化。EIP-1967 把關鍵的代理變數(實作、管理員)放到由雜湊衍生的固定、偽隨機儲存槽,使它們不會與一般的循序變數碰撞。實作合約應停用自己的初始化函式(在建構子中呼叫 OpenZeppelin 的 _disableInitializers),使任何人都無法直接初始化並劫持邏輯合約。絕不要 delegatecall 進不受信任或任意的地址,並把 delegatecall 目標的每一個位元組都當成自己的程式碼看待,因為對 EVM 而言,它就是。
代理把擁有者存在第 0 槽;經由 delegatecall 觸及的實作,卻把第 0 槽當成一個普通的使用者值。一個呼叫實作 setter 的使用者寫入第 0 槽,便悄悄成了代理的擁有者。固定槽方案(EIP-1967)可防止這種重疊。
2017 年 11 月的 Parity 多簽凍結(約 51.3 萬枚 ETH)就是一個 delegatecall 漏洞:共用函式庫被留在未初始化狀態,攻擊者宣告擁有權並將其自毀,報廢了每一個程式碼寄居在那個函式庫裡的錢包。