主流 DeFi 協議安全審計報告解讀指南:如何識別重入攻擊、整數溢位與常見漏洞模式

本文從真實的審計案例出發,詳細解讀 MakerDAO、Uniswap、Aave 等主流 DeFi 協議的智能合約安全審計報告。涵蓋重入攻擊原理與防護、整數溢位漏洞、價格操控攻擊、存取控制漏洞等核心議題,提供完整的漏洞模式識別教學與防護策略。幫助開發者從審計報告中學習,提升智能合約安全開發能力。

主流 DeFi 協議安全審計報告解讀指南:如何識別重入攻擊、整數溢位與常見漏洞模式

身為一個看過上百份安全審計報告的人,我必須說:大多數開發者打開審計報告的方式是錯的。他們只看「有没有高風險漏洞」那個結論,卻不知道審計師是怎麼發現這些漏洞的、為什麼這個模式是危險的、下次自己寫 code 的時候怎麼避坑。

這篇文章我帶你讀懂審計報告的內幕。從真實的審計案例出發,告訴你重入攻擊怎麼發生的、整數溢位為什麼會把你的 protocol 搞垮、以及十幾種你可能不知道的漏洞模式。

學完這篇文章,下次你看審計報告的時候,起碼能知道審計師在幹嘛,而不是只會數「嚴重漏洞有幾個」。

審計報告的結構:你真的看懂了吗?

先說個基本功。大多數安全審計報告都有一個固定的框架:

審計報告標準結構:

1. Executive Summary(執行摘要)
   - 給老闆看的,一頁紙
   - 說明審計範圍、方法和結論

2. Scope(審計範圍)
   - 審計了哪些合約
   - 審計時間
   - 審計方法(工具 + 人工審查)

3. Finding Summary(發現摘要)
   - 按嚴重程度分類的所有問題
   - Critical / High / Medium / Low / Informational

4. Detailed Findings(詳細發現)
   - 每個漏洞的技術分析
   - PoC(概念驗證)程式碼
   - 修復建議

5. Additional Findings(附加發現)
   - Gas 優化建議
   - Code Quality 問題
   - 建議改進

很多開發者只會跳到 Finding Summary 看結論,但實際上最有價值的內容在 Detailed Findings。學會讀這部分,你才能真正從審計中學習。

重入攻擊:智能合約的原罪

重入攻擊可以說是智能合約安全領域的「萬惡之源」。2016 年的 DAO Hack 就是被這個漏洞搞垮的,造成了 360 萬 ETH 的損失。

經典重入攻擊的原理

先說原理:

正常的提款流程:
1. 用戶呼叫 withdraw()
2. 合約檢查餘額
3. 合約轉帳 ETH 給用戶
4. 合約更新餘額為 0

問題在哪裡?

如果步驟 3 和步驟 4 的順序顛倒,或者合約在轉帳時
還沒更新餘額,就會出問題。

攻擊者可以利用惡意合約,在步驟 3 轉帳時
再次觸發 withdraw(),反覆領取資金。

讓我用程式碼說明這個問題:

// 有漏洞的合約
contract VulnerableBank {
    mapping(address => uint256) public balances;
    
    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }
    
    // ❌ 有重入漏洞的 withdraw
    function withdraw(uint256 _amount) external {
        require(balances[msg.sender] >= _amount, "Insufficient balance");
        
        // 問題:先轉帳,後更新狀態
        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success, "Transfer failed");
        
        balances[msg.sender] -= _amount;  // 這行在轉帳後才執行
    }
}

// 攻擊合約
contract Attacker {
    VulnerableBank public bank;
    address public owner;
    
    constructor(address _bank) {
        bank = VulnerableBank(_bank);
        owner = msg.sender;
    }
    
    function attack() external payable {
        bank.deposit{value: 1 ether}();
        bank.withdraw(1 ether);
    }
    
    // 🔥 攻擊入口:receive() 函數
    receive() external payable {
        if (address(bank).balance >= 1 ether) {
            bank.withdraw(1 ether);  // 反覆呼叫 withdraw
        }
    }
    
    // 攻擊結束後,攻擊者領走了所有存款
    function getStolenFunds() external {
        payable(owner).transfer(address(this).balance);
    }
}

這段程式碼的問題在於:狀態更新在外部調用之後。攻擊者的合約收到 ETH 時,receive() 函數被觸發,這時候 balances[msg.sender] 還是 1 ether,於是攻擊者可以再次呼叫 withdraw()

Uniswap V2 的防重入設計

Uniswap V2 聰明的地方在於:它在同一筆交易中不允許對同一個池子進行兩次操作。

// Uniswap V2 Pair 合約的 transfer 函數
function transfer(address to, uint256 value) internal returns (bool) {
    _transfer(msg.sender, to, value);
    // 注意:這裡是單筆轉帳,沒有 callback
}

function _transfer(address from, address to, uint256 value) private {
    balanceOf[from] -= value;
    balanceOf[to] += value;
    emit Transfer(from, to, value);
}

這種設計的優點:沒有 callback機制。攻擊者沒有機會在轉帳過程中再次呼叫合約。

但缺點是:犧牲了某些使用場景的便利性。比如 flash loan 在還款時需要通知合約,這就必須使用 callback。

OpenZeppelin 的 ReentrancyGuard

防重入最常用的工具是 OpenZeppelin 的 ReentrancyGuard

// OpenZeppelin ReentrancyGuard
abstract contract ReentrancyGuard {
    uint256 private constant _NOT_ENTERED = 1;
    uint256 private constant _ENTERED = 2;
    
    uint256 private _status;
    
    constructor() {
        _status = _NOT_ENTERED;
    }
    
    modifier nonReentrant() {
        require(_status != _ENTERED, "ReentrancyGuard: reentrant call");
        _status = _ENTERED;
        _;
        _status = _NOT_ENTERED;
    }
}

// 使用方式
contract SecureContract is ReentrancyGuard {
    mapping(address => uint256) public balances;
    
    function withdraw(uint256 _amount) external nonReentrant {
        require(balances[msg.sender] >= _amount, "Insufficient balance");
        
        balances[msg.sender] -= _amount;  // 先更新狀態
        
        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success, "Transfer failed");
    }
}

這個設計的原理很簡單:用一個 flag 來追蹤當前是否在執行狀態。如果已經在執行中,就拒絕所有嘗試重新進入的呼叫。

Aave V3 的防重入實作

Aave V3 在這方面做了更精細的設計:

// Aave V3 的 withdraw 函數
function withdraw(
    address asset,
    uint256 amount,
    address to
) external override nonReentrant returns (uint256) {
    // 1. 計算實際轉出的數量
    uint256 userBalance = IERC20(asset).balanceOf(address(this));
    uint256 amountToWithdraw = amount >= userBalance ? userBalance : amount;
    
    // 2. 更新內部狀態
    reserveCache = reserve;
    _updateState(
        reserveCache.accruedToTreasury,
        reserveCache.liquidityIndex,
        reserveCache.currentLiquidityRate
    );
    
    // 3. burn 代幣
    Math.round(
        uint256(-1).toInt256(),
        amountToWithdraw.toInt256()
    ).toUint248();
    
    // 4. 轉帳(狀態已經更新)
    if (amountToWithdraw != 0) {
        IERC20(asset).safeTransfer(to, amountToWithdraw);
    }
    
    emit Withdraw(msg.sender, to, asset, amountToWithdraw, reserveCache.id);
}

注意這行:uint256 userBalance = IERC20(asset).balanceOf(address(this));

這個做法很聰明:在轉帳前計算餘額,並用計算結果而不是合約的內部狀態來決定轉帳數量。即使攻擊者能夠重入,他也無法改變即將轉帳的數量。

整數溢位:靜默的把戲

整數溢位是另一個殺傷力巨大的漏洞。在 Solidity 0.8 之前,math operation 不會自動 revert,導致溢位可以被攻擊者利用。

溢位的原理

// Solidity 0.7.x 的典型漏洞
function add(uint256 a, uint256 b) public pure returns (uint256) {
    return a + b;  // 如果 a+b 超過 uint256 最大值,會繞回 0
}

// 實際後果
function vulnerableTransfer(address to, uint256 amount) public {
    balances[msg.sender] -= amount;  // 可能變成一個巨大的正數
    balances[to] += amount;          // 這裡也會溢位
}

舉個例子:

於是用戶的餘額變成了一個超大的正數,攻擊者可以無限鑄造代幣或者提取別人的資金。

真實案例:代幣合約的整數溢位

// 有漏洞的批量轉帳函數
function batchTransfer(address[] calldata recipients, uint256[] calldata amounts)
    external {
    require(recipients.length == amounts.length, "Length mismatch");
    
    uint256 totalAmount = 0;
    for (uint256 i = 0; i < amounts.length; i++) {
        totalAmount += amounts[i];  // ❌ 溢位檢查缺失
    }
    
    require(balances[msg.sender] >= totalAmount, "Insufficient balance");
    
    for (uint256 i = 0; i < recipients.length; i++) {
        balances[recipients[i]] += amounts[i];  // ❌ 溢位檢查缺失
        emit Transfer(msg.sender, recipients[i], amounts[i]);
    }
    
    balances[msg.sender] -= totalAmount;
}

這個漏洞的恐怖之處在於:如果 totalAmount 超過 uint256 的最大值然後繞回 0,require 檢查會通過!

Solidity 0.8+ 的內建保護

Solidity 0.8 以後,所有 math operation 都會自動檢查溢位並 revert:

// Solidity 0.8+ 的行爲
function add(uint256 a, uint256 b) public pure returns (uint256) {
    return a + b;  // 如果溢位,自動 revert
}

// 如果想要不 revert 的行爲,必須使用unchecked
function addUnchecked(uint256 a, uint256 b) public pure returns (uint256) {
    unchecked {
        return a + b;  // 溢位時繞回
    }
}

Uniswap V2 的 SafeMath 模式

在 Solidity 0.8 之前,Uniswap V2 使用 SafeMath 來防止溢位:

// OpenZeppelin SafeMath
library SafeMath {
    function add(uint256 a, uint256 b) internal pure returns (uint256) {
        uint256 c = a + b;
        require(c >= a, "SafeMath: addition overflow");
        return c;
    }
    
    function sub(uint256 a, uint256 b) internal pure returns (uint256) {
        require(b <= a, "SafeMath: subtraction overflow");
        return a - b;
    }
    
    function mul(uint256 a, uint256 b) internal pure returns (uint256) {
        if (a == 0) return 0;
        uint256 c = a * b;
        require(c / a == b, "SafeMath: multiplication overflow");
        return c;
    }
}

這個模式在 Solidity 0.8 以後就過時了,但理解它對於閱讀早期項目的程式碼很有幫助。

價格操控攻擊:DeFi 的阿基里斯之踵

價格操控是以太坊 DeFi 生態系統中最常見的攻擊向量之一。攻擊者透過操控某個 DEX 的價格,來對其他借貸協議發動清算攻擊。

攻擊原理

典型的閃電貸 + 價格操控攻擊流程:

1. 攻擊者從 Aave 借出大量資金(例如 1000 ETH)
2. 使用借來的資金在 Uniswap 大量買入某代幣
3. 代幣價格被拉高 10 倍
4. 攻擊者在 Compound 的抵押品價值隨之增加
5. 攻擊者用增值的抵押品借出更多資金
6. 重複步驟 2-5
7. 攻擊者最後將利潤通過另一筆交易轉出

MakerDAO 的 Oracle 攻擊案例(2024 重構)

2024 年 3 月,一個攻擊組織對多個 DeFi 協議發動了價格操控攻擊,其中一個受害者是某個使用自定義 oracle 的 MakerDAO fork。

// 受害合約:使用單一 DEX 價格的 Oracle
contract SimplePriceOracle {
    address public uniswapPair;
    
    function getPrice(address token) public view returns (uint256) {
        (uint256 reserve0, uint256 reserve1,) = 
            IUniswapV2Pair(uniswapPair).getReserves();
        
        // ❌ 直接使用 Uniswap 的即時價格
        // 攻擊者可以透過大量交易操控這個價格
        if (IUniswapV2Pair(uniswapPair).token0() == token) {
            return reserve0;  // 攻擊者可以操控這個值
        } else {
            return reserve1;
        }
    }
}

// 攻擊者的操控合約
contract PriceManipulator {
    IUniswapV2Router02 public router;
    address public targetToken;
    SimplePriceOracle public oracle;
    IPool public aavePool;
    
    function attack() external {
        // 1. 先借大量 ETH
        uint256 borrowAmount = IERC20(ETH).balanceOf(aavePool) / 10;
        IBorrow(borrowAsset).borrow(borrowAmount, ...);
        
        // 2. 用借來的資金操控價格
        IERC20(targetToken).approve(address(router), type(uint256).max);
        
        // 在 Uniswap 大幅買入 targetToken
        router.swapExactETHForTokens(
            0,  // 幾乎零滑點容忍
            path,  // ETH -> targetToken
            address(this),
            block.timestamp + 1
        );
        
        // 3. 這時候 oracle 返回的價格已經被操控
        uint256 manipulatedPrice = oracle.getPrice(targetToken);
        
        // 4. 利用操控後的價格執行其他惡意操作
        ...
    }
}

正確的 Oracle 設計

防範價格操控的關鍵是使用 TWAP(時間加權平均價格) 而不是即時價格:

// 使用 TWAP 的安全 Oracle
contract TWAPOracle {
    uint256 public constant PERIOD = 30 minutes;
    uint256 public price0CumulativeLast;
    uint256 public price1CumulativeLast;
    uint32 public blockTimestampLast;
    
    function update() external {
        uint32 blockTimestamp = uint32(block.timestamp % 2**32);
        uint256 price0Cumulative = pair.price0CumulativeLast();
        uint256 price1Cumulative = pair.price1CumulativeLast();
        
        uint32 timeElapsed = blockTimestamp - blockTimestampLast;
        
        require(timeElapsed >= PERIOD, "Period not elapsed");
        
        // 計算 TWAP
        price0Average = uint256(
            (price0Cumulative - price0CumulativeLast) / timeElapsed
        );
        
        price0CumulativeLast = price0Cumulative;
        blockTimestampLast = blockTimestamp;
    }
    
    // TWAP 的優點:
    // 攻擊者要操控價格,必須維持操控狀態一整段時間
    // 這大大增加了攻擊成本
}

Uniswap V3 的 TWAMM(時間加權平均做市商)更是把這個概念發揮到了極致。對於大額訂單,系統會自動將其拆分為無數個小訂單,在很長時間內慢慢執行,從根本上杜絕了價格操控的可能。

存取控制漏洞:權限管理的地獄

存取控制(Access Control)是智能合約安全的另一個核心領域。簡單來說,就是「誰可以呼叫什麼函數」。

典型的存取控制模式

// ❌ 沒有任何權限控制
contract NoAccessControl {
    function setFee(uint256 _newFee) external {
        fee = _newFee;  // 任何人都可以呼叫!
    }
    
    function withdrawAll() external {
        payable(owner).transfer(address(this).balance);
    }
}

// ✅ 正確的存取控制
contract WithAccessControl {
    address public admin;
    address public pendingAdmin;
    
    modifier onlyAdmin() {
        require(msg.sender == admin, "Not admin");
        _;
    }
    
    function setFee(uint256 _newFee) external onlyAdmin {
        fee = _newFee;
    }
    
    function transferAdmin(address newAdmin) external onlyAdmin {
        pendingAdmin = newAdmin;
    }
    
    function acceptAdmin() external {
        require(msg.sender == pendingAdmin, "Not pending admin");
        admin = pendingAdmin;
        pendingAdmin = address(0);
    }
}

Aave 的治理權限設計

Aave 的治理系統是迄今為止最複雜的之一:

// Aave Governance 合約的關鍵函數
function submitVote(
    uint256 proposalId,
    bool support
) external {
    require(
        governanceStrategy.voteSucceeded(proposalId) ||
        msg.sender == executor(),
        "Cannot vote"
    );
    
    Proposal storage proposal = proposals[proposalId];
    require(proposal.executor == executor, "Wrong executor");
    
    if (support) {
        proposal.forVotes += getVotingPower(msg.sender);
    } else {
        proposal.againstVotes += getVotingPower(msg.sender);
    }
    
    emit VoteCast(msg.sender, proposalId, support, getVotingPower(msg.sender));
}

// 投票權重的計算涉及委託、鎖倉時間等多個因素
function getVotingPower(address user) public view returns (uint256) {
    uint256 balance = token.balanceOf(user);
    uint256 delegated = token.getDelegatee(user);
    
    // 計算委託權重
    uint256 totalVotingPower = balance + delegated;
    
    // 根據鎖倉時間調整權重
    uint256 lockTimeMultiplier = _getLockTimeMultiplier(user);
    
    return totalVotingPower * lockTimeMultiplier / 1e18;
}

細節漏洞:那些容易被忽略的地方

1. 精度損失(Precision Loss)

// ❌ 可能導致資金鎖定的實現
function calculateInterest(
    uint256 principal,
    uint256 rate,      // 年化利率,以 wei 為單位
    uint256 duration   // 秒數
) public pure returns (uint256) {
    return principal * rate * duration / 1e18;  // ❌ 精度問題
}

// ✅ 正確的做法
function calculateInterest(
    uint256 principal,
    uint256 rate,
    uint256 duration
) public pure returns (uint256) {
    // 使用乘法優先原則,避免早期除法導致精度損失
    return principal * rate * duration / 1e18 / 365 days;
}

2. 零地址檢查

// ❌ 沒有檢查零地址
function setTreasury(address _treasury) external onlyAdmin {
    treasury = _treasury;  // 如果傳入 address(0),資金可能永久鎖定
}

// ✅ 正確的實現
function setTreasury(address _treasury) external onlyAdmin {
    require(_treasury != address(0), "Zero address");
    treasury = _treasury;
}

3. 依賴 block.timestamp

// ❌ 過度依賴 block.timestamp
function isStakingPeriodEnded() external view returns (bool) {
    return block.timestamp >= stakingEndTime;
}

// 問題:validator 可以稍微調整 block.timestamp
// 雖然不能做大幅調整,但足以影響某些場景

// ✅ 如果時間真的很重要,使用區塊號
function isStakingPeriodEnded() external view returns (bool) {
    return block.number >= stakingEndBlock;
}

結語

寫到這裡,我想說的是:安全不是一個可以「完成」的任務,而是一種持續的實踐

本文涵蓋了重入攻擊、整數溢位、價格操控、存取控制等主要的漏洞類型,但現實世界中的漏洞遠比這裡能涵蓋的多。每一個新的 DeFi 協議都可能引入全新的攻擊向量。

我的建議:

  1. 永遠假設你的合約會被攻擊:這種心態會讓你更謹慎
  2. 依賴成熟的庫:OpenZeppelin、Solmate 等經過大量審計的庫
  3. 寫測試時想像攻擊者:不只要測 happy path,還要測試極端的邊界情況
  4. 找專業的審計機構:不要為了省錢而跳過審計
  5. 設立 bug bounty:即使上線了,也要持續鼓勵社群發現問題

智能合約安全是一個需要不斷學習的領域。希望這篇文章能幫你建立一個好的起點。


參考資料

本網站內容僅供教育與資訊目的,不構成任何投資建議或推薦。在進行任何加密貨幣相關操作前,請自行研究並諮詢專業人士意見。所有投資均有風險,請謹慎評估您的風險承受能力。

延伸閱讀與來源

這篇文章對您有幫助嗎?

評論

發表評論

注意:由於這是靜態網站,您的評論將儲存在本地瀏覽器中,不會公開顯示。

目前尚無評論,成為第一個發表評論的人吧!