지금까지 몇개월정도 Web3 offensive 보안 공부를 하면서 취약한 컨트랙트를 보면 일단 call()함수가 사용되었나를 보고, 사용되었으면 reentrancy attack이 가능하겠다~ 정도를 생각해왔다.
그리고, call{value: }() 함수 사용으로 인한 reentrancy attack의 countermeasure로 사용되는 함수가 send(), transfer() 인 것으로 생각하고 있었다.
그런데 몇몇 자료들을 찾아다니다보니, 꼭 그런것도 아니겠구나 싶었다. 오히려 send(), trnasfer() 함수가 reentrancy attack을 유발할 수 있겠구나 싶었다.
이 내용을 한번 자세히 정리해보고자 한다.
지난번 포스팅에서 송금 관련 함수들 (send, transfer, call)에 대해 다룬 적이 있었다. 한번 들어가서 보고와도 좋을 것 같다.
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() 메서드를 통해 어떻게 공격을 할 수 있다는 것일까?
우측 컨트랙트에서 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 > Web3 dev&sec' 카테고리의 다른 글
[Web3 개발/보안] contract 함수 직접호출 vs 저수준 호출 (0) | 2024.05.19 |
---|---|
[Web3 개발/보안] Solidity payable / transfer, call, send / 송금 관련 함수 다루기 (0) | 2024.04.12 |
[web3 Solidity] modifier 함수변경자 / onlyOwner (0) | 2024.04.11 |