iT邦幫忙

0

Day 15:delegatecall 與代理模式的風險(storage 被覆寫)

  • 分享至 

  • xImage
  •  

為了讓智能合約「可升級」,常見作法是把邏輯(Logic/Implementation)與資料(Proxy / Storage)分離,利用 delegatecall 在代理合約(Proxy)上下文執行邏輯合約程式碼。
這個模式很強大,但 若沒處理好 storage layout(儲存槽位對齊)或升級權限,會導致資料被覆寫或被惡意替換邏輯而產生嚴重風險。

  1. delegatecall 以呼叫者(Proxy)的 storage 執行外部邏輯,會使用呼叫者的儲存槽位。
  2. 若 Implementation 與 Proxy 的 storage 佈局不同,delegatecall 會把資料寫到錯誤的槽位,造成狀態毀損。
  3. 升級權限失控或未驗證的 Implementation 被替換,可能導致惡意程式在 Proxy 上操控資金。

🔍 call vs delegatecall

[Proxy Contract Storage] <--- delegatecall (執行時使用 Proxy 的 storage)
|
+-- slot 0: owner
+-- slot 1: someValue
+-- slot 2: balanceMapping ...

Proxy.delegatecall(implementationAddress, data) --> implementation's code runs,
but reads/writes Proxy's storage slots.

• call: 以被呼叫合約的 context(storage)執行 → 不會影響呼叫者 storage。
• delegatecall: 以呼叫者(Proxy)的 context 執行被呼叫合約的程式碼 → 會讀寫呼叫者 storage(因此很容易發生槽位錯配)。

🧾 問題來源:Storage Layout(槽位對齊)

Solidity 對 state 變數按宣告順序分配 storage slot(每 32 bytes 為一 slot)。
若 Proxy 與 Implementation 在變數宣告順序或數量不同,delegatecall 會把 Implementation 的變數寫到 Proxy 不相干的槽位,導致資料錯亂或某些關鍵變數(例如 owner、admin)被覆寫。

❌ 典型錯誤範例(易造成 storage 被覆寫)

Proxy(儲存 owner)
// Proxy (simplified)
contract Proxy {
address public owner; // slot 0
address public implementation; // slot 1

function fallback() external payable {
    (bool success, ) = implementation.delegatecall(msg.data);
    require(success);
}

}

Implementation V1(邏輯合約)
contract LogicV1 {
uint256 public counter; // slot 0 <-- BUT when delegatecall runs, it will map to Proxy.slot0 (owner)!
function inc() public { counter += 1; }
}

結果:counter(implementation.slot0)在 delegatecall 時會寫入 Proxy 的 owner 槽位,導致 owner 被覆寫成數值(錯誤),合約變為不可預期狀態。

✅ 正確做法與防護策略

  1. 固定 Proxy 的 storage 佈局(使用 unstructured storage / EIP-1967、EIP-1822)
    • 不在 Proxy 中直接宣告常規 state 變數(如 owner、implementation)於容易衝突的槽位。
    • 使用指定 slot(例如 keccak-256 標識符)來儲存 implementation 地址與 admin,避免與 implementation 的槽位對撞。
    • 例如:bytes32 constant IMPLEMENTATION_SLOT = keccak256("my.proxy.implementation");

  2. 使用成熟框架(如 OpenZeppelin Upgrades)
    • OpenZeppelin 已針對 Proxy 升級模式實作並處理 storage layout(EIP-1967)。採用成熟套件可大幅減少踩雷機率。

  3. 嚴格管理升級權限(Upgrade Admin)
    • 升級功能應該只給可信任的多簽(multi-sig)或 DAO。
    • 在升級前需審計及驗證新的 Implementation 合約碼(checksum / bytecode verification)。

  4. 保守的 storage 佈局協定(Storage Gap)
    • Implementation 合約在末端保留若干 uint256[50] private __gap ; 做為未來變數擴充空間,避免未來升級時對齊錯誤。

  5. 測試升級路徑(Upgrade Tests)
    • 在本地做完整升級流程測試:V1 → V2,檢查所有重要 state(owner、balances、mappings)是否一致。

範例:使用 Unstructured Storage(簡化示意)
// Proxy: use specific slot to store implementation
contract SimpleProxy {
// keccak256("example.proxy.implementation") 存 implementation address
bytes32 private constant IMPLEMENTATION_SLOT = keccak256("example.proxy.implementation");

function _getImpl() internal view returns (address impl) {
    bytes32 slot = IMPLEMENTATION_SLOT;
    assembly { impl := sload(slot) }
}

function _setImpl(address impl) internal {
    bytes32 slot = IMPLEMENTATION_SLOT;
    assembly { sstore(slot, impl) }
}

fallback() external payable {
    address impl = _getImpl();
    (bool ok, ) = impl.delegatecall(msg.data);
    require(ok);
}

}

🔑 常見實務注意點(Checklist)
• 是否使用 EIP-1967 或 OpenZeppelin 的 proxy 標準?
• 升級權限是否由 multi-sig 或 DAO 控管?
• Implementation 是否保留 storage gap?
• 是否在升級前做完整的升級測試(state 保持一致)?
• 是否驗證新的 implementation bytecode / source code?

Proxy + delegatecall 是讓智能合約「能變」的好方法,但同時也把「資料一致性」的重擔丟給了開發者。
實際上,很多升級事故不是因為惡意,而是因為「槽位對齊沒設計好」或「升級流程沒控管好」。
建議是:若非必要,不要輕易自造升級機制;若要升級,就用成熟工具並把升級權交給多簽/治理。


圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言