본문 바로가기
Hack/Cryptocurrency

Transaction Order Dependence Attack in Smart Contract

by Becoming a Hacker 2023. 5. 5.
반응형

Transaction Order Dependence Attack도 Smart Contract에서 발생할 수 있는 공격인데요.

 

Smart Contract는 블록체인 기술에서 중요한 개념 중 하나로, 계약 체결과 실행을 자동으로 수행할 수 있는 프로그램이며, 블록체인 기술을 활용하여 제 3자의 인증 기관 없이 개인 간 계약이 이루어질 수 있도록 하는 기술입니다.

 

그런데 Smart Contract는 사람이 직접 코드를 개발해야 하기 떄문에 코드 상의 문제 또는 버그가 존재할 경우 예상치 못한 결과가 발생할 수 있습니다. 그 중 하나인 Reentrancy Attack은 아래의 포스팅을 통해 확인하실 수 있습니다!

 

Reentrancy Attack in Smart Contract

Smart Contract는 블록체인 기술에서 중요한 개념 중 하나로, 계약 체결과 실행을 자동으로 수행할 수 있는 프로그램이며, 블록체인 기술을 활용하여 제 3자의 인증 기관 없이 개인 간 계약이 이루어

hacksms.tistory.com


Transaction Order Dependence / Front-Running Attack

BlockChain에서는 Transaction 생성 순서와 Transaction 체결 순서가 항상 일치하지 않습니다.

 

사용자가 노드에 Transaction을 전송하면 즉시 Block에 추가되는 것이 아니라 채굴되지 않은 Transaction들이 Mempool이라는 일종의 대기실에 보관됩니다.

 

그리고 이렇게 저장된 Transaction은 GAS 또는 FEE 라고 부르는 체결 수수료가 클수록 Transaction의 검증을 담당하는 채굴자 또는 Validator들이 해당 Transaction을 더 높은 우선 순위로 처리하려고 합니다. 즉, 높은 수수료를 지불하는 Transaction은 더 빠르게 처리됩니다.

 

위와 같은 BlockChian 상의 특징을 이용하여 사용자가 의도하지 않은 흐름으로 Transaction 순서를 변경하여 공격을 수행할 수 있습니다. 이러한 공격을 Transaction Order Dependence 또는 Front-Running 이라고 부릅니다.

 

그럼 이제 Transaction Order Depdence를 통하여 발생할 수 있는 위협 시나리오에 대하여 알아보도록 해보겠습니다.

 

1. 가격 변경을 통한 공격

 

만약 아래와 같이 악의적인 목적을 가지고 발행된 Smart Contract가 있다고 가정해보겠습니다.

pragma solidity ^0.8.0;

contract TODSample {
    uint256 public price;
    address public owner;
    mapping(address => uint256) private userBalances;

    event Purchase(address indexed buyer, uint256 price);
    event PriceChange(address indexed owner, uint256 price);

    modifier onlyOwner() {
        require(msg.sender == owner, "Only owner can call this function");
        _;
    }

    constructor() {
        owner = msg.sender;
        price = 100;
    }

    function deposit() external payable {
        userBalances[msg.sender] += msg.value;
    }

    function getUserBalance(address user) external view returns (uint256) {
        return userBalances[user];
    }

    function buy() external returns (bool) {
        require(userBalances[msg.sender] >= price, "Insufficient balance");
        userBalances[msg.sender] -= price;
        owner.transfer(price);
        emit Purchase(msg.sender, price);
        return true;
    }

    function setPrice(uint256 newPrice) external onlyOwner {
        require(newPrice > 0, "Price should be greater than zero");
        price = newPrice;
        emit PriceChange(owner, price);
    }
}

 

위 Smart Contract는 owner가 설정한 가격으로 특정한 상품을 살 수 있는 기능을 하고 있습니다. 코드만 봤을 때는 큰 문제가 없어보이지만, 사실은 Transaction의 순서에 따라 사용자의 자산을 크게 탈취할 수 있습니다.

 

공격자는 아래와 같은 시나리오를 통해 스마트 컨트랙트의 코드를 믿고 입금한 사용자의 자산을 탈취할 수 있습니다. 

 

1. 일반 사용자가 deposit() 함수를 이용하여 Smart Contract에 자산을 입금함 (Tx1)

 

2. 일반 사용자는 buy() 함수를 이용하여 상품 구매를 요청함 (Tx2)

  • 이때 상품의 가격은 100임

3. 공격자(Owner)는 setPrice() 함수를 이용하여 상품의 가격을 높게 변경함 (Tx3)

  • 이때 공격자는 채굴 수수료를 높게 설정하여 먼저 처리되도록 함

4. 채굴 수수료에 따라 Tx3가 먼저 체결된 뒤, Tx2가 이후에 체결되면서 높은 가격으로 상품이 구매됨

 

 

조치 방안

 

취약한 Smart Contract를 안전한 코드로 변경하여 배포하기 위해서는 어떤 조치를 수행해야 될까요? 

 

여러 가지 방법이 있을 수 있지만, 아래 샘플 코드와 같이 가격 변동 시 txCounter를 증가시켜 변동 이전에 발생한 거래는 실행되지 못하도록 막는 등의 방법을 사용할 수도 있습니다.

pragma solidity ^0.8.0;

contract TODSample {
    uint256 public price;
    address public owner;
 	uint256 public txCounter;
    mapping(address => uint256) private userBalances;

    event Purchase(address indexed buyer, uint256 price);
    event PriceChange(address indexed owner, uint256 price);

    modifier onlyOwner() {
        require(msg.sender == owner, "Only owner can call this function");
        _;
    }

    constructor() {
        owner = msg.sender;
        price = 100;
        txCounter = 0;
    }

    function deposit() external payable {
        userBalances[msg.sender] += msg.value;
    }

    function getUserBalance(address user) external view returns (uint256) {
        return userBalances[user];
    }

    function buy() external returns (bool) {
        require(_txCounter == txCounter);
        require(userBalances[msg.sender] >= price, "Insufficient balance");
        userBalances[msg.sender] -= price;
        owner.transfer(price);
        emit Purchase(msg.sender, price);
        return true;
    }

    function setPrice(uint256 newPrice) external onlyOwner {
        require(newPrice > 0, "Price should be greater than zero");
        price = newPrice;
        txCounter += 1;
        emit PriceChange(owner, price);
    }
}

2. 정답 갈취

 

만약 A와 B가 각각 1ETH의 자산을 걸고 정답을 맞추는 게임을 하고 있다고 가정해보겠습니다.

pragma solidity ^0.8.0;

contract Betting {
    uint256 public constant betAmount = 1 ether; // 각 플레이어의 베팅 금액

    address payable public playerA;
    address payable public playerB;

    bytes32 public answerHash; // 정답을 해싱한 값의 해시
    bool public isGameFinished; // 게임이 끝났는지 여부

    event GameFinished(address winner, uint256 answer); // 게임 종료 시 발생할 이벤트

    // 생성자에서 각 플레이어를 설정하고 정답을 해싱한 값의 해시를 설정합니다.
    constructor(address payable _playerA, address payable _playerB, bytes32 _answerHash) payable {
        require(msg.value == 2 * betAmount, "Each player must send 1 ether");
        playerA = _playerA;
        playerB = _playerB;
        answerHash = _answerHash;
    }

    // 정답을 맞추는 함수입니다.
    function guess(uint256 _guess) external {
        require(!isGameFinished, "Game is finished");

        address payable player = payable(msg.sender);
        require(player == playerA || player == playerB, "You are not a player");

        bytes32 guessHash = keccak256(abi.encodePacked(_guess)); // 플레이어가 추측한 값을 해싱한 값의 해시를 계산합니다.
        if (guessHash == answerHash) { // 플레이어가 추측한 해시 값이 정답의 해시 값과 일치하면
            msg.sender.transfer(2 * betAmount)
            isGameFinished = true;
            emit GameFinished(player, _guess); // 이벤트를 발생시키면서 정답 값을 함께 전달합니다.
        }
    }
}

 

위 Smart Contract는 Player A와 B가 각각 1 ETH를 베팅한 뒤, 특정 값을 찾는 게임입니다. 코드만 봤을 때는 큰 문제가 없어보이지만, 사실은 Transaction의 순서에 따라 사용자의 자산을 크게 탈취할 수 있습니다.

※ 정답은 Hash 값으로 저장되어 숨겨져있음

 

공격자(Player B)만 정답을 모르고 있는 상황에서도 TOD Attack을 이용하여 공격자는 ETH를 탈취할 수 있습니다.

 

1. 정답을 알고 있는 Player A와 정답을 모르고 있는 공격자 Player B

 

2. A는 자신이 알고 있는 정답을 guess()함수를 이용하여 전송 (Tx1)

 

3. B는 Mempool를 지켜보고 있다가 A가 올린 정답을 확인한 뒤, 더 높은 채굴 수수료를 포함한 guess() 함수로 정답 전송 (Tx2)

 

4. 채굴 수수료에 따라 Tx2가 먼저 체결되어, Player B에게 베팅된 ETH가 반환됨

 

 

조치 방안

 

안전한 게임 환경을 위해 취약한 Smart Contract를 안전한 코드로 변경하기 위해서는 어떤 조치를 수행해야 될까요? 여러 가지 방법이 있을 수 있지만, commit-reveal scheme와 같은 방법을 이용하여 이러한 공격을 사전에 예방할 수도 있습니다.

 

Commit (제출)

  • 답을 쓰려는 사용자는 답+Salt에 대한 Hash 값을 생성 후 Smart Contract에 제출

Reveal (밝히다)

  • Commit을 통해 정답 기회를 획득한 사용자는 답과 Salt를 Smart Contract에 제출
  • Smart Contract는 제출 받은 정답이 실제 정답과 동일한 지 확인 수행
  • 만약 정답이 맞다면, 이전에 제출한 Hash 값과 답+Salt를 통하여 생성된 Hash 값이 동일한 지 확인
  • 동일하다면 유효한 대답으로 간주하여 보상을 제공하고 동일하지 않다면 보상을 제공하지 않음
pragma solidity ^0.8.0;

contract Betting {
    uint256 public constant betAmount = 1 ether; 

    address payable public playerA;
    address payable public playerB;

    bytes32 public answerHash; 
    bool public isGameFinished; 

    mapping(address => bytes32) public commitments;
    mapping(address => bool) public canReveal;

    event GameFinished(address winner, uint256 answer); 

    constructor(address payable _playerA, address payable _playerB, bytes32 _answerHash) payable {
        require(msg.value == 2 * betAmount, "Each player must send 1 ether");
        playerA = _playerA;
        playerB = _playerB;
        answerHash = _answerHash;
    }

    function commit(bytes32 _commitment) external {
        require(!isGameFinished, "Game is finished");
        require(msg.sender == playerA || msg.sender == playerB, "You are not a player");
        commitments[msg.sender] = _commitment;
        canReveal[msg.sender] = true;
    }

    function reveal(uint256 _guess, bytes32 _salt) external {
        require(!isGameFinished, "Game is finished");
        require(canReveal[msg.sender], "You cannot reveal your answer");
        require(commitments[msg.sender] == keccak256(abi.encodePacked(_guess, _salt)), "Invalid commitment");

        if (keccak256(abi.encodePacked(_guess, _salt)) == answerHash) {
            msg.sender.transfer(2 * betAmount);
            isGameFinished = true;
            emit GameFinished(msg.sender, _guess);
        }

        canReveal[msg.sender] = false;
    }
}

3. 샌드위치 공격

 

샌드위치 공격은 주로 DeFi를 대상으로 발생하며, 공격자의 두 Transaction이 피해자의 Transaction 사이에 껴 있는 모습을 보고 지어졌습니다.

 

샌드위치 공격은 주로 아래와 같은 흐름으로 발생합니다.

 

1. 공격자는 Mempool에서 공격할 Transaction을 모색

 

2. 이때 유동성 풀에서 대규모 Token A -> Token B 스왑 Transaction 발견 (Tx1)

  • 정상적인 스왑 비율은 1000 : 1

3. 공격자는 두 개의 Transaction을 생성

  • Token A -> Token B 스왑 Transaction 생성 (Tx2)
    • 이때 채굴 수수료가 Tx1보다 높음
  • TokenB -> Token A 스왑 Transaction 생성 (Tx3)
    • 이때 채굴 수수료가 Tx1보다 낮음

4. 이후 채굴 수수료에 따라 Tx2 -> Tx1 -> Tx3 흐름으로 공격이 수행됩니다.

  • Tx2의 거래로 인하여 스왑 비율은 1100 : 1로 변하며, 공격자는 Token B를 보유하게 됨
  • 이후 Tx1의 거래로 인하여 스왑 비율은 1300 : 1로 변하며, Token B의 가격이 크게 상승함
  • Tx3 거래로 인하여 공격자는 높은 가격으로 Token B를 Token A로 변경하여 이득을 볼 수 있음

 

샌드위치 공격에 한하여 뚜렷한 대응 방안을 아직 확인하지 못함


Reference

 

Transaction order dependence attack in smart contract

The transaction life cycle in most Blockchain technologies is mainly controlled by miners, even the order of the transactions. Therefore, when two users execute

www.getsecureworld.com

 

Front Running and Sandwich Attack Explained | QuillAudits

Table of Content:Front-running attacks take advantage of the process by which transactions are added to the blockchain’s distributed…

quillaudits.medium.com

 

A Beginner's Guide to Sandwich Attacks in DeFi

This guide breaks down the components of a sandwich attack and explains how to stay as safe as possible in DeFi ecosystems.

beincrypto.com

 

DEFI Sandwich Attack Explain

In this article, I am going to summarise what is sandwich attacks with an example and mathematical view so you can understand crypto market…

medium.com

 

댓글