반응형

지금까지 몇개월정도 Web3 offensive 보안 공부를 하면서 취약한 컨트랙트를 보면 일단 call()함수가 사용되었나를 보고, 사용되었으면 reentrancy attack이 가능하겠다~ 정도를 생각해왔다.

그리고, call{value: }() 함수 사용으로 인한 reentrancy attack의 countermeasure로 사용되는 함수가 send(), transfer() 인 것으로 생각하고 있었다.

그런데 몇몇 자료들을 찾아다니다보니, 꼭 그런것도 아니겠구나 싶었다. 오히려 send(), trnasfer() 함수가 reentrancy attack을 유발할 수 있겠구나 싶었다.

이 내용을 한번 자세히 정리해보고자 한다.

 


지난번 포스팅에서 송금 관련 함수들 (send, transfer, call)에 대해 다룬 적이 있었다. 한번 들어가서 보고와도 좋을 것 같다.

 

[Web3 개발/보안] Solidity payable / transfer, call, send / 송금 관련 함수 다루기

오늘은 web3에서 Solidity를 사용하는 사람이라면 한 번쯤은 짚고넘어가야할 필요가 있는 payable 키워드를 들고와보았다. 처음 solidity를 공부할 때 부터 대체 저 키워드가 무엇인지부터 시작해서 tran

nullorm.tistory.com


Reentrancy attack (재진입 공격)

말 그대로 attacker가 victim contract의 송금 함수를 여러번 실행하도록 하는 공격이다. 예시를 한번 살펴보자.

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

contract Victim {
	mapping(address => uint256) public balance;

	function withdraw() external {
		require(balance[msg.sender] >= 1, "insufficient balance"); // ... 1
		(bool sent, ) = payable(msg.sender).call{value: 1 ether}(""); // ... 2
		balance[msg.sender] = balance[msg.sender] - 1; // ... 3
	}
}

이렇게 생긴 Victim 컨트랙트가 있다고 할 때, Attacker가 withdraw() 함수를 정상적으로 실행하면,

1. require()에서 잔액이 1 이더 이상 있는지 물어보고

2. 1 이더를 msg.sender(Attacker)에게 보내주고

3. msg.sender(attacker)의 잔액을 1만큼 감소시켜준다.

 

여기까지 봤을 때는 아무런 문제가 없어보인다. 하지만... Solidity에는 receive(), fallback() 메소드가 존재한다.

receive() method

Web3 생태계는 아무래도 돈을 송금하는 기능에 초점을 맞춘 기능이 많다보니 receive()와 같은 메소드가 생겨났다.   

receive() 는 컨트랙트의 함수를 호출하는 것이 아니라, 컨트랙트에 이더를 송금했을 때 실행되는 부분이라고 보면 된다.

예시를 살펴보자.

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

contract Victim {
	mapping(address => uint256) public balance;

	function withdraw() external {
		require(balance[msg.sender] >= 1, "insufficient balance"); // ... 1
		(bool sent, ) = payable(msg.sender).call{value: 1 ether}(""); // ... 2
		balance[msg.sender] -= 1; // ... 3
	}
	
	receive() external payable {
		balance[msg.sender] += msg.value;
	}
}

위의 컨트랙트를 살펴보면, receive() 메소드를 통해 msg.sender 주소의 balance 매핑을 증가시켜주는 것을 알 수 있다.

그렇다면, 이러한 receive() 메서드를 통해 어떻게 공격을 할 수 있다는 것일까?

좌측: victim, 우측: attacker

 

우측 컨트랙트에서 doWithdraw()를 실행시켜 좌측 컨트랙트의 withdraw()함수를 실행시키면...

1. require() 를 통해 msg.sender의 잔액을 체크한다

2. call()을 통해 1이더를 송금한다

3. msg.sender의 balance를 1만큼 감소시킨다. 

지금까지는 이런 줄 알았지만 사실은,

 

1. require() 를 통해 msg.sender의 잔액을 체크한다

2. call()을 통해 1이더를 송금한다

   2-1: 이더를 받은 우측 컨트랙트에서 receive()를 실행시킨다

   2-2: doSomething

3. msg.sender의 balance를 1만큼 감소시킨다. 

이런 순서로 진행되는 것이다.

 

그런데, 여기에서 중요한 취약점은, require()에서 잔액을 검사하고, call()을 한 뒤에, balance를 감소시킨다.

그런데, balance 매핑을 감소시키기 전에, receive()가 실행되어 다른 동작을 할 수 있다.

이를 조금 악용해볼까?

우측 컨트랙트의 "//doSomething" 부분만 수정하였다.

이렇게 되면, 어떤 과정으로 실행되는지 다시 보자.

 

1. require() 를 통해 msg.sender의 잔액을 체크한다

2. call()을 통해 1이더를 msg.sender에게 송금한다

   2-1: 이더를 받은 우측 컨트랙트에서 receive()를 실행시킨다

   2-2: 다시한번 좌측의 withdraw함수를 실행시킨다.

   2-3: 좌측에서 require()를 통해 balance 매핑을 체크하지만, 아직 balance가 마이너스 되지 않았다.

   2-4: 다시한번 call()을 통해 1이더를 msg.sender에게 송금한다.

        2-4-1: 이더를 받은 우측 컨트랙트에서 receive()를 실행시킨다

        2-4-2: 다시한번 좌측의 withdraw함수를 실행시킨다.

        2-4-3: 좌측에서 require()를 통해 balance 매핑을 체크하지만, 아직 balance가 마이너스 되지 않았다.

        2-4-4: 다시한번 call()을 통해 1이더를 msg.sender에게 송금한다.

              ...

              ...

              ...

3. msg.sender의 balance를 1만큼 감소시킨다. 

 

이런 순서로 진행되는 것이다.

결국, Victim 컨트랙트는 더이상 송금을 할 수 있는 이더가 없을 때 까지 attacker에게 송금을 하게 될 것이고, 가진 모든 이더를 빼앗기게 될 것이다.

 

이게 가능한 이유는?

call()함수가 transfer(), send() 함수와 다르게, gas의 최대 사용량이 한정되지 않고 로직에 따라 gas 사용량이 가변적이기 때문에, call() 함수가 상대적으로 재진입공격에 취약한 것이고 transfer, send 함수가 안전하다고 하는 것이다.


send(), transfer() 함수의 취약성

그런데, 사실 send(), transfer() 함수도 취약하다고 본다.

https://consensys.io/diligence/blog/2019/09/stop-using-soliditys-transfer-now/

이 자료를 보면, 특정 Opcode 실행 시 gas fee를 변경하면서부터, gas fee를 제한하는 것을 통한 countermeasure가 별로 의미있지 않게 된 다는 것이다. 즉, gas fee가 내려간다면 2300 gas만으로도 재진입공격이 가능해질 수 있고, gas fee가 올라가게 된다면, 정상 컨트랙트도 실행을 실패하게될 수 있다는 것이다.

따라서 이제는, send / transfer 함수를 통해 송금을 구현하는 것 보다는, call{value:, gas:}("") 의 형태로 구현하는 것이 더욱 바람직할 것이라고 한다.

 

반응형
반응형

web3 생태계에서 다른 컨트랙트의 함수를 호출하는 과정에서 우리는 어떨 때는 직접 호출을 하는 경우도 있고, 저수준 호출을 하는 경우도 있다. 

A라는 컨트랙트에 있는 "func1(uint _number)" 함수를 호출한다고 가정해보자.

그러면 우리는, 두 가지 호출 방식이 있을 것이다. 

 

1. A.func1(123) - 직접호출 방식

2. A.call{}(abi.encodeWithSignature("func1(uint)")) - 저수준 호출 방식

 

이 두 경우의 차이점, 그리고 호출 실패 시 어떻게 핸들링하는지를 알아보고자 한다.


1. 직접호출 방식

직접 호출 방식은 가장 기본적인 방법으로, 호출하려는 함수의 이름과 인수를 사용하여 함수를 호출한다. 이 방식은 Solidity 컴파일러에 의해 함수의 존재와 시그니처가 검증되므로 타입 안정성을 제공한다. 

호출 방법은 위에서도 썼듯,

A.func1(123);

이다. 

이 방식의 장점이라고 한다면 가독성 및 안정성이라고 할 수 있을 것이다. 컴파일타임에 함수의 존재를 확인하고, 인자타입이 맞지 않을 경우 컴파일 에러를 발생시킨다. 

또한, 리턴 값을 받는 것도 굉장히 직관적이라고 할 수 있다. 

만약 "func1"함수가 uint형을 return 한다면

uint res = A.func1(123);

으로 리턴을 받을 수 있다.

함수 호출 실패 시

함수가 존재하지 않거나, 인자가 잘못되거나 했을 경우, 컴파일 시점에 에러를 발생시키지만, 컴파일 시점에 문제가 없는 modifier, revert, require 등으로 인해 함수 호출이 실패했을 경우, 전체 트랜잭션이 실패하고 롤백되게 된다.

2. 저수준 호출

저수준의 'call'함수를 사용하게 되면 더욱 유연하지만 복잡하고 에러가 발생하기 쉬운 방식의 호출이 된다.

'call'은 ABI 인코딩을 통해 어떠한 함수든 호출할 수 있고, 이 때 함수의 존재여부나 시그니처 등은 컴파일이 아닌, 런타임에 검증된다.

(bool success, bytes memory data) = A.call{value: msg.value, gas: 5000}(abi.encodeWithSignature("func1(uint)", 123));

이런식으로 사용을 하게 된다.

success 의 경우, 함수 호출이 성공적이었는지 여부를 나타내며,

data 의 경우, 호출된 함수로부터 반환된 return 값이다.

이렇게 반환된 return값은, abi.decode() 메서드를 통해 적절하게 처리할 수 있다.

(bool success, bytes memory data) = A.call(abi.encodeWithSignature("func1(uint)", 123));
require(success, "Call failed");

uint result;
if (data.length > 0) {  // 함수에서 데이터가 실제로 반환되었는지 확인
    result = abi.decode(data, (uint));
}

이런식으로 사용할 수 있다.

함수 호출 실패 시

저수준 함수 호출에는, 에러가 발생한다고 해도 롤백이 되지 않는다. 대신, success플래그를 통해 성공 여부를 반환하게 된다. 

(bool success, bytes memory data) = A.call{gas: 5000}(abi.encodeWithSignature("func1(uint)", 123));
if (!success) {
    // 에러 핸들링 로직
    revert("Function call failed.");
}

그래서, 함수 호출에 실패했을 경우, 위와 같이 직접 핸들링을 해주어야 한다. 다소 불편할 수 있지만, 유연성을 제공한다는 점에서 장점이 있다고 할 수 있다.

반응형
반응형

오늘은 web3에서 Solidity를 사용하는 사람이라면 한 번쯤은 짚고넘어가야할 필요가 있는 payable 키워드를 들고와보았다. 

처음 solidity를 공부할 때 부터 대체 저 키워드가 무엇인지부터 시작해서 transfer, call, send로 대체 왜 나뉘는지, 대체 어떻게 쓰는건지도 어려웠던 것 같은 기억이 있는데, 나처럼 삽질하지 말고 이거 하나 보고 그냥 가볍게 넘어가보도록 하자.

^__^

 

1. payable 키워드

다른 프로그래밍 언어와 달리, solidity는 가상화폐(코인)을 동력으로 하는 언어이다. 그렇다 보니, 다른 언어에서는 쓰이지 않는 송금 관련 함수 또는 키워드들이 존재하곤 한다. 즉, 가상화폐(코인)로 접근하기 위한 키워드가 사용된다는 것이다.

 

이더리움(ethereum) 생태계 내의 solidity 언어 내에서 코인을 전송하는 컨트랙트를 작성하기 위해서는 반드시 payable 키워드가 필요하다. 즉, payable 키워드를 사용하지 않는다면 ether를 보낼 수 없다는 것이다. 

 

일단 어떻게 사용되는지 예제를 한번 볼까?

    address payable public owner;
  	
    constructor() payable {
        owner = payable(msg.sender);
    }
    
    function deposit() public payable {}

 

이렇게 사용이 된다고 보면 될 것 같은데,,, 이렇게 보면 대체 어떻게 사용하라는건지 감이 잘 잡히지 않는다.

좀 더 자세히 들어가보자.

 

payable의 사용은 크게 두 가지로 나뉜다.

  • address payable: address type과 함께 사용되며, payable 키워드가 붙은 address 변수 등은 payable 해진다.
  • function payable: function을 선언할 때 사용되며, payable 키워드가 붙은 함수는 지불, 잔액확인 등의 기능을 사용할 수 있다.

1-1. address payable

payable 키워드가 address 타입에 적용될 경우, 해당 주소는 ETH를 받을 수 있게 된다.

예를 들어, 사용자로부터 ETH를 받아 해당 금액을 contract 내부의 주소로 보내고 싶을 떄, payable 키워드를 사용할 수 있다.

address payable public recipient = 0x123123123...; // 이더를 받을 수 있는 주소 생성
recipient.transfer(amount); // 이더 전송

크게 어려울 것은 없기 때문에 넘어가도 될 듯 하다.

1-2. function payable

payable 키워드가 함수 타입에 적용될 경우, 그 함수는 ETH를 보내거나 받는 것, 특정 주소의 계좌 잔액을 확인하는 등의 기능을 실행할 수 있게 된다.

contract MyContract {
    function deposit() public payable {
        // 이 함수를 호출할 때 보낸 ETH는 자동적으로 이 컨트랙트의 Balance에 추가된다.
    }

    function getBalance() public view returns (uint) {
        return address(this).balance;
    }
}

 

payable주소, 함수를 이용하면 '.transfer', '.send' 등의 메서드를 이용하여 ETH를 보낼 수 있게 된다.

이제 좀 더 구체적인 사용법들을 알아보도록 하자.

 

2. ETH 송금 함수: .transfer(), .send(), .call()

ETH를 컨트랙트를 통해 보내기 위해 사용할 수 있는 함수 몇 가지를 알아보도록 하자.

비슷한 기능을 하는 대표적으로 세 가지 함수가 존재하는데, 이들의 차이점에 주목하면서 한번 같이 따라가보자.

입력값, 리턴값이 어떻게 다른지도 살펴보며 진행해보자.

2-1. .transfer()

사용 예시 먼저 보자.

address payable recipient = payable(0x123...); // 수신자 주소
uint256 amount = 1 ether;

recipient.transfer(amount); // 이더 전송

맨 아래, recipient.transfer(amount); 부분을 보면 된다.

  • recipient는 송금받을 사람의 주소이며,
  • amount는 송금을 할 액수이다.
  • 리턴값이 존재하지 않는다.

.transfer()함수의 특징을 알아보자. 

.transfer()함수는 지정된 금액의 ETH를 해당 주소로 전송하도록 설계되어있으며, 전송이 실패하게 될 경우, 자동으로 예외(exception)을 발생시키고, 트랜잭션을 되돌린다(revert). revert시에는 사용된 gas를 제외한 나머지가 반환된다. (2300가스를 소모함)

따라서, 에러 발생 또는 송금 실패 시에 error를 리턴할 필요가 없다.

 

송금 함수 중 가장 안전한 함수이기도 하다.

2-2. .send()

.send()함수는 .transfer()과 비슷하지만, 실패 시 트랜잭션을 revert()하지 않고, 실패(False)를 반환한다는 특징이 있다.

사용 예시를 확인해보자. 

address payable recipient = payable(0x123...);
uint amount = 1 ether;

bool sent = recipient.send(amount);
require(sent, "Failed to send Ether");
  • recipient는 송금받을 사람의 주소이며,
  • amount는 송금을 할 액수이다.
  • 리턴값이 존재한다.

 

transfer()에서는 에러를 리턴하지 않고 직접 핸들링하기 때문에 이럴 필요가 없었지만, send()에서는 직접 핸들링하는 로직을 작성해주어야 한다. 

이 역시도 2300gas를 소모한다는 특징이 있다.

 

.send()함수는 보통 송금 실패시 자동으로 트랜잭션을 revert()하는 기능이 없기 때문에, .transfer()함수에 비해 유연한 오류처리가 필요할 때 사용한다고 보면 될 것 같다.

2-3. .call()

.call()메서드는 Solidity에서 가장 유연한 메서드 중 하나로, 모든 종류의 함수를 호출하고, 이더를 전송할 수 있다. 

사용 예시를 보자.

address payable recipient = payable(0x123...);
uint amount = 1 ether;

(bool success, bytes memory data) = recipient.call{value: amount}("");
require(success, "Failed to send Ether");

이를 보면, 리턴값으로 boolean값과 bytes memory 데이터를 튜플 형태로 받는 것을 알 수 있다. 

  • recipient는 송금받을 사람의 주소이다.
  • amount는 송금을 할 액수이다. (사용법: .call{value: amount}("");)
  • 성공 여부를 튜플 형태로 반환한다.

call()을 사용하게 되면, gas비용이 그 로직에 따라 가변적이기 때문에 가스 소모량을 조절할 수 있다는 장점이 있고,

call()의 실행을 실패했을 때, 자동으로 트랜잭션을 revert하지 않기 때문에 반환 성공 여부를 체크하여 이후 로직을 처리할 필요가 있다. 하지만, call() 함수는 보안에 취약할 수 있으므로 주의할 필요가 있다. (보통 어떠한 함수가 유연하게 사용된다고 하면, 보안에 취약할 경우가 많다.) 

아무래도 보안 전공생이니, 이 부분은 나중에 따로 다뤄보겠다.

 

오늘 내용 끝~

반응형
반응형

Solidity를 하다보면, onlyOwner라는 이름의 modifier가 많이 쓰인다. 그냥 항상 송금/출금 등의 함수에서 당연히 붙이는거니까~ 하면서 쓰고있었는데, 정확한 문법에 대한 이해가 필요할 듯 해 정리해보려고 한다.

    modifier onlyOwner() {
        require(msg.sender == owner, "caller is not the owner");
        _;
    }
    
    function withdraw() public onlyOwner {
        payable(owner).transfer(address(this).balance);
    }

 

줄마다 보면서 해석해봐야할 것 같다.

1. modifier 함수변경자의 의미

modifier는 "함수변경자, 제어자" 라는 이름으로도 불린다. 즉, 함수의 기능에 있어 특정 기능을 제한/변경할 수 있다는 것이다. 

맨 위의 코드를 한번 보자. 

modifier onlyOwner()

onlyOwner라는 이름의 modifier를 만들어준다. 

onlyOwner는 기본적으로 함수에 이 modifier를 추가해주면, 오직 컨트랙트의 소유자만이 해당 함수를 호출할 수 있게 된다.

정확한 내용을 살펴보자.

 

require(msg.sender == owner, "caller is not the owner");
_;

msg.sender의 주소가 owner의 주소와 같지 않다면, 에러메시지 "caller is not the owner"를 출력하고, 함수의 실행을 막는다. 여기까진 오케이. 그 다음에 등장하는 "_;"는 대체 무엇일까?

 

이는, 다시 modifier를 호출한 함수로 돌아가라는 말이다.

이게 무슨 말이지?

 

다시 맨 위의 코드를 보자.

위 코드에서 withdraw() 함수의 위치에서 onlyOwner modifier가 사용되었고, 그 즉시 컨트랙트는 onlyOwner modfier가 선언된 곳으로 가서 해당 함수를 호출한 사람의 주소와 컨트랙트 소유자의 주소가 같은지를 비교한다. 

비교를 한 이후, '_;'부분은, 이제 onlyOwner를 호출한 함수의 나머지 부분을 실행하라는 말이다 즉, 위 코드는 다음과 같다고 할 수 있다.

    function withdraw() public {
    	require(msg.sender == owner, "caller is not the owner");
        payable(owner).transfer(address(this).balance);
    }

이와 똑같은 코드라고 생각하면 된다.

그래서, '_;'는 모든 modifier의 마지막에 항상 넣어준다고 생각하면 된다.

 

그럼, 저렇게 쓰면 되는데 왜 굳이 modifier라는걸 만들어서 사용하지..?

확장성을 위함이겠지..ㅎ

 

2. onlyOwner는 왜 있을까?

onlyOwner는 ownable이라는 컨트랙트에 내장되어있다. 

이렇게까지 라이브러리화 하여 사용하는 이유가 있을까?

 

그 이유는, 블록체인이라는 기술의 특성에 있다.

모두 알겠지만, 블록체인의 중요한 특성 중 하나는 바로, 그 기록들의 '불변성'이다.

이게 무슨말이냐면, 제아무리 컨트랙트를 직접 만든 개발자라고 하더라도, 그 코드를 맘대로 바꿀 수는 없다는 것이다. 

 

EVM(Ethereum Virtual Machine)은 그 구조적 특성상, 컨트랙트 코드의 컴파일 결과물인 바이트코드는 EVM 내의 불변의 영역인 'Storage'에 들어가있다. 때문에 미리 정의된 코드는 그 내용을 절대 바꿀 수 없게 된다.

 

이로 인해서, 솔리디티 코드를 작성할 때 가장 중요한 것은 보안 다음으로, 유지보수성이다. 즉, 지금 짠 코드를 나중에 어떠한 일이 생겼을 때 언제든 바꿀 수 있어야 한다는 것이다. 

따라서 modifier와 같은 제어자 또한 사용해주고, constructor 등의 메서드, 상수 사용의 최소화 등을 해야 한다.

 

말을 하다 보니 onlyOwner의 필요성은 좀 뒷전으로 하긴 했는데, 이렇게 변수의 사용이 자주 바뀔 수 있게 되다 보니 (owner 등의 변수 포함) 컨트랙트의 ownership 또한 중요하기도 하고, 토큰을 거래하는 데 주로 초점이 맞추어져있는 solidity의 특성 상, 송금 또는 출금을 하는 함수도 많다보니 편리성을 위해 존재하기도 하는, 아주 요긴한 녀석이다.

반응형

+ Recent posts