블록체인/실제 공격 사례

Rubixi - 접근 제어 공격

Ocean_Onx 2024. 3. 26. 21:02
728x90
반응형

Rubixi 란?

그림 1 - 한때 존재했었던 Rubixi 홈페이지의 홍보 문구

 

 스마트 컨트랙트[각주:1]에 대한 실제 공격 사례를 조사하던 중, 골 때리는 사례를 발견하게 되어 이를 소개하고자 한다. 오늘 소개할 Rubixi[각주:2]는 이더리움 블록체인 상에 존재하는 스마트 컨트랙트로써, 다단계 사기를 위한 컨트랙트이다. 그런데 이 컨트랙트는 어떤 취약점으로 인해, 사기로 벌어들인 모든 수익을 해킹당했다. 쉽게 말해 해커가 사기꾼을 정의구현한 사건이다.

 

 위 그림 1의 내용을 엄청 간단하게 설명하자면, 1 Eth[각주:3]를 송금하면 3 Eth로 돌려줄 테니, 이 컨트랙트에 돈을 송금하라는 이야기이다. 스마트 컨트랙트 특성상 코드가 전부 공개되기 때문인지 일말의 숨기려는 의지 없이, 홈페이지에 대문짝만 하게 "새로우면서도 검증된 다단계"라고 적어 놓은 게 실로 골때린다[각주:4].

 

 지금부터 이 글의 남은 부분에서는 Rubixi 코드가 어떤 식으로 동작하는지 그림과 함께 쉽게 풀어 설명하고, 실제 코드를 살펴볼 것이다. 그런 다음 코드의 어떤 부분이 문제가 되어 해커에게 공격받았는지 조사하고, 마지막으로 이러한 취약점을 노출하지 않는 안전한 코드 작성법에 대해 소개하며 마무리한다.


Rubixi 동작 과정

 어떤 사용자 A가 Rubixi에 이더를 송금한다고 가정해 보자. 이때, 만약 1 Eth보다 적은 이더를 송금하게 된다면 그냥 Rubixi가 수수료로 해당 금액을 챙겨버리고 아무 동작도 일어나지 않는다. 1 Eth 이상의 금액을 송금해야 본격적으로 다단계(Pyramid)를 위한 코드가 실행된다.

 

 사용자 A는 해당 다단계의 구성원으로 포함되고, 컨트랙트는 A의 주소와 배당금(돌려받기로 약속된 양의 이더)의 정보를 저장한다. A가 송금한 이더는 Rubixi의 잔고에 추가되는데, 이때 10%의 수수료는 Rubixi가 챙기게 된다[각주:5].

 

 다단계에 뛰어든 참가자의 수에 따라 돌려받을 수 있는 배당률은 달라지게 된다. 최초 10명의 사용자는 자신들이 Rubixi에 송금한 양 보다 3배 많은 이더를 돌려받게 되며, 그다음 25명까지는 2배, 나머지 참여자들은 1.5배 많은 이더를 돌려받을 수 있다.

 

 당연하게도 돌려받기로 약속된 금액을 바로 돌려주지 않는다. 앞서 말했듯, 사용자들이 송금한 이더가 Rubixi의 잔고에 추가되는데, 이 잔고에 쌓인 돈이 현재 배당금을 받을 차례인 사용자의 배당금보다 많아지면, 그때 이더를 보내준다.

 

 이 컨트랙트를 등록한 소유자(주인)는 이러한 일련의 과정 속에서 쌓이게 되는 수수료를 통해 수익을 얻는다.

 

 눈에 훤히 보이는 사기이긴 하나, 스마트 컨트랙트라서 생기게 되는 재미있는 특징이 있다. 돌려주기로 한 돈을 돌려주지 않고 컨트랙트의 잔고에서 이더를 훔쳐 도망간다던가, 자기 마음대로 수수료를 높인다거나, 자기 마음대로 배당률을 낮춘다던가 하는 것은 불가능하다. 이는 개발자 임의로 컨트랙트에 작성되지 않은 행동을 수행하는 것이 불가능[각주:6]하기 때문이다.

 

 따라서 컨트랙트의 소유자는 수수료를 챙겨가는 방법 이외에는 이익을 얻을 수 있는 방법이 전혀 없다. 뻔한 폰지 사기이긴 하나, 약속은 반드시 지키는 사기라는 점이 재미있다.

그림 2 - 대략적인 Rubixi 동작과정을 나타낸 순서도


Rubixi 코드 분석

 Rubixi의 코드는, 기능에 따라 크게 4가지 부분으로 나눌 수 있다.

 

1. 다단계 동작을 위한 코드

2. 컨트랙트 주인이 수수료를 챙겨가기 위한 코드

3. 수치 조정/수정을 위한 코드

4. 현재 다단계가 어떤 상태에 놓여 있는지 확인하기 위한 코드

 

 변수 및 함수들을 전부 소개하면 아래와 같다.

더보기
분류 이름 설명
구조체 (struct) Participant 참여자 정보를 나타내는 구조체
주소와 배당금을 저장함
변수 (variable) participants 참여자들의 배열
balance Rubixi에 쌓인 잔고
collectedFees Rubixi에 쌓인 수수료
feePercent 수수료율
pyramidMultiplier 배당률
payoutOrder 다음으로 배당금을 받을 순번
creator Rubixi의 소유자/주인
생성자 (constructor) DynamicPyramid Rubixi의 생성자[각주:7]
다단계 동작을 위한 함수 fallback Rubixi에 이더 송금시 호출되는 함수
init 수수료율을 설정하고, 1 eth보다 적은 액수는 그냥 흡수하는 함수
addPayout 다단계 실현을 위한 다양한 동작 실행
1. 배당률 설정
2. 잔고 추가
3. 수수료 징수
4. 배당금 반환 및 배당 순서 업데이트
수수료를 챙겨가기 위한 코드 collectAllFees 쌓인 수수료 전부를 creator로 송금
collectFeesInEther 임의의 양만큼 creator로 수수료 송금
collectPercentOfFees 임의의 비율만큼 creator로 수수료 송금
수치 조정/수정을 위한 코드 changeOwner Rubixi의 소유자/주인 변경
changeMultiplier 배당률 변경
changeFeePercentage 수수료율 변경
다단계 상태 확인을 위한 코드 currentMultiplier 현재 배당률 반환
currentFeePercentage 현재 수수료율 반환
currentPyramidBalanceApproximately 현재 쌓인 잔고 양 반환
nextPayoutWhenPyramidBalanceTotalIsApproximately 다음 차례 배당금의 액수
feesSeperateFromBalanceApproximately 현재 쌓인 수수료 양 반환
totalParticipants 총 참여자 수 반환
numberOfParticipantWaitingForPayout 배당금을 기다리는 참여자 수 반환
participantDetails 특정 참여자의 주소 및 배당금 정보 반환

 

 

 아래는 Rubixi의 컨트랙트 코드를 ^0.8.24 버전의 컴파일러에서 컴파일 가능하도록, 일부분을 수정한 코드이다. 원본 코드 및 컨트랙트는 이곳에서 확인 가능하다.

더보기
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract Rubixi {

    //Declare variables for storage critical to contract
    uint private balance = 0;
    uint private collectedFees = 0;
    uint private feePercent = 10;
    uint private pyramidMultiplier = 300;
    uint private payoutOrder = 0;

    address private creator;

    //Sets creator
    function DynamicPyramid() public {
        creator = msg.sender;
    }

    modifier onlyowner {
        if (msg.sender == creator) _;
    }

    struct Participant {
        address etherAddress;
        uint payout;
    }

    Participant[] private participants;

    //Fallback function
    receive() external payable {
        init();
    }

    //init function run on fallback
    function init() private {
        //Ensures only tx with value of 1 ether or greator are processed and added to pyramid
        if (msg.value < 1 ether) {
            collectedFees += msg.value;
            return;
        }

        uint _fee = feePercent;
        //50% fee rebate on any ether value of 50 or greator
        if (msg.value >= 50 ether) _fee /= 2;

        addPayout(_fee);
    }

    //Function called for valid tx to the contract
    function addPayout(uint _fee) private {
        //Adds new address to participant array
        participants.push(Participant(msg.sender, (msg.value * pyramidMultiplier) / 100));

        //These statements ensure a quicker payout system to later pyramid entrants, so the pyramid has a longer lifespan
        if (participants.length == 10) pyramidMultiplier = 200;
        else if (participants.length == 25) pyramidMultiplier = 150;

        //Collect fees and update contract valance
        balance += (msg.value * (100 - _fee)) / 100;
        collectedFees += (msg.value * _fee) / 100;

        //Pays eariler participants if balance sufficient
        while (balance > participants[payoutOrder].payout) {
            uint payoutToSend = participants[payoutOrder].payout;
            payable(participants[payoutOrder].etherAddress).transfer(payoutToSend);

            balance -= participants[payoutOrder].payout;
            payoutOrder += 1;
        }
    }

    //Fee functions for creator
    function collectAllFees() public onlyowner {
        if (collectedFees == 0) revert();

        payable(creator).transfer(collectedFees);
        collectedFees = 0;
    }

    function collectFeesInEther(uint _amt) public onlyowner {
        _amt *= 1 ether;
        if (_amt > collectedFees) collectAllFees();

        if (collectedFees == 0) revert();

        payable(creator).transfer(_amt);
        collectedFees -= _amt;
    }

    function collectPercentOfFees(uint _pcent) public onlyowner {
        if(collectedFees == 0 || _pcent > 100) revert();

        uint feesToCollect = collectedFees / 100 * _pcent;
        payable(creator).transfer(feePercent);
        collectedFees -= feesToCollect;
    }

    //Functions for changing variables related to the contract
    function changeOwner(address _owner) public onlyowner {
        creator = _owner;
    }

    function changeMultiplier(uint _mult) public onlyowner {
        if (_mult > 300 || _mult < 120) revert();

        pyramidMultiplier = _mult;
    }

    function changeFeePercentage(uint _fee) public onlyowner {
        if (_fee > 10) revert();
        
        feePercent = _fee;
    }

    //Functions to provide information to end-user using JSON interfaces
    function currentMultiplier() public view returns(uint multiplier, string memory info) {
        multiplier = pyramidMultiplier;
        info = "This multiplier applies to you as soon as transaction is received, may be lowered to hasten payouts or increased if payouts are fast enough. Due to no float or decimals, multiplier is x100 for a fractional multiplier e.g. 250 is actually 2.5x multiplier. capped at 3x max and 1.2x min.";
    }

    function currentFeePercentage() public view returns(uint fee, string memory info) {
        fee = feePercent;
        info = "Shown in % form. Fee is halved(50%) for amounts equal or greator than 50 ethers. (fe may change, but is capped to a maximum of 10%)";
    }

    function currentPyramidBalanceApproximately() public view returns(uint pyramidBalance, string memory info) {
        pyramidBalance = balance / 1 ether;
        info = "All balance values are measured in Ethers, note that due to no decimal placing, these values show up as integers only, within the contract itself you will get the exact decimal value you are supposed to";
    }

    function nextPayoutWhenPyramidBalanceTotalIsApproximately() public view returns(uint balancePayout) {
        balancePayout = participants[payoutOrder].payout / 1 ether;
    }

    function feesSeperateFromBalanceApproximately() public view returns(uint fees) {
        fees = collectedFees / 1 ether;
    }

    function totalParticipants() public view returns(uint count) {
        count = participants.length;
    }

    function numberOfParticipantsWaitingForPayout() public view returns(uint count) {
        count = participants.length - payoutOrder;
    }

    function participantDetails(uint orderInPyramid) public view returns(address Address, uint Payout) {
        if (orderInPyramid <= participants.length) {
            Address = participants[orderInPyramid].etherAddress;
            Payout = participants[orderInPyramid].payout / 1 ether;
        }
    }
}

공격 취약점

 Rubixi 소유자의 다단계를 향한 꿈은, 컨트랙트가 등록되고 얼마 지나지 않아 처참히 박살 났다. 그것도 너무나 어이없는 이유로.

    //Sets creator
    function DynamicPyramid() public {
        creator = msg.sender;
    }

 

 위 함수는 Rubixi의 생성자이다. 좀 더 정확히는 생성자로 만들고자 작성한 함수였다. 본래 Rubixi의 제작자들은 이 컨트랙트와, 이 다단계 프로젝트 및 회사명?을 DynamicPyramid로 지으려고 했으나, 나중에 Rubixi로 이름을 변경하였다. 그런데 생성자의 이름은 변경하지 않았다. 그래서 위 함수는, 누구나 호출 가능한 평범한 public 함수가 되어버렸다. 단 한 줄짜리 함수이긴 하나, 그 한 줄이 너무 치명적이었다. 해당 컨트랙트의 소유자를 설정하는 코드였기 때문이다. 다시 말해, creator만 컨트랙트에 쌓인 수수료를 가져갈 수 있었으나, 그 creator가 누구나 될 수 있었다.

 

 이 모든 내용을 요약하자면 다음과 같다.

 

 Rubixi에 대한 공격의 성공은 잘못된 생성자 선언으로 인해, 관리자의 권한을 제 3자가 쉽사리 획득하는 취약점이 노출된 것이 주요 원인이 되었다.


예방법

 최신 버전에 맞는 양식으로 코드를 작성한다면, 그냥 constructor 키워드를 선언하고 코드를 작성하면 된다.

    //Sets creator
    constructor() public {
        creator = msg.sender;
    }

 

 0.4.21 버전 이하에서 작성한다면(이제 와서 왜 그러는지는 모르겠지만), 아래와 같이 함수 이름을 똑바로 작성하는 것으로 해결할 수 있다.

    //Sets creator
    function Rubixi() public {
        creator = msg.sender;
    }

  1. 블록체인 상에 등록되는 공유되고 불변하며 자동 이행되는 프로그램/계약이라 이해할 수 있다. 쉽게 말하자면 그 어떤 누구도(심지어 제작자마저도) 수정 불가능한 프로그램이며, 조건이 만족되면 자동으로 동작이 수행되는 계약이라 할 수 있다. [본문으로]
  2. 해당 컨트랙트 및 코드는 여기서 확인 가능하다: https://etherscan.io/address/0xe82719202e5965Cf5D9B6673B7503a3b92DE20be#code [본문으로]
  3. 이더리움 네트워크에서 통용되는 암호 화폐의 가장 큰 단위. 쉽게 말하자면 미국의 화폐 단위는 센트, 달러등이 있는데, 이 중 가장 큰 화폐 단위는 달러이다. Eth가 이와 같다. [본문으로]
  4. 다단계의 특성상 자기가 받을 돈을 다음 사람들이 채우기만 한다면, 이득을 취할 수 있긴 하다. 거기다 자동/강제 이행되는 스마트 계약의 특성상 돈이 쌓이면, 반드시 배당금을 돌려주기 때문에 계약 불이행등의 문제는 없을 것이다. 그래서 뻔히 보이는 사기임에도 사람들이 자신의 돈을 투자했나 싶기도 하다(NFT 마냥). 그러나 다단계라는 특성상 이득을 취하는 사람은 소수이고, 언젠가는 더 이상의 참여자가 생기지 않아 대다수는 3배의 돈은커녕 원금조차 돌려받지 못할 것이다. [본문으로]
  5. 만약 50 Eth 이상 송금하게 된다면 수수료는 5%로 줄어든다. [본문으로]
  6. 이는 블록체인 및 스마트 컨트랙트의 특성 때문이다. [본문으로]
  7. Rubixi의 생성자여야 했으나 아래의 취약점 관련 부분에서 후술하듯, 실제로는 생성자가 아닌 평범한 public 함수가 되었다. [본문으로]
728x90
반응형