스마트 컨트랙트 공격 - 서비스 거부 공격 (DoS)
⛔️ 서비스 거부 공격 (Denial of Service)
서비스 거부 공격은 악의적인 공격자가 네트워크 또는 시스템을 과부하시키거나 새로운 연결을 거부하여 정상적인 사용자들이 서비스를 이용할 수 없도록 만드는 공격입니다.
이러한 공격은 서버 또는 네트워크 자원을 초과해서 사용하여 서비스를 정상적으로 처리할 수 없게 만드는 것이 목표입니다.
❗️ 예기치 않은 Revert
아래는 예기치 않은 Revert가 DoS를 유발할 수 있는 방법을 설명하기 위한 입찰 스마트 컨트랙트입니다.
😭 피해자 스마트 컨트랙트
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;
contract Auction {
address frontRunner;
uint256 highestBid;
function bid() public payable {
require(msg.value > highestBid, "Need to be higher than highest bid");
// 기존 입찰자에게 Ether를 전송합니다. 실패시 revert 됩니다.
require(payable(frontRunner).send(highestBid), "Failed to send Ether");
frontRunner = msg.sender;
highestBid = msg.value;
}
}
- 입찰 금액이 기존 highestBid 금액보다 높아야 합니다.
- 이전 입찰 금액을 이전 입찰자에게 환불합니다.
- 두 조건이 모두 충족되면 최고 입찰가(highestBid)와 최고 입찰자(frontRunner)를 새 값으로 업데이트합니다.
🤪 공격자 스마트 컨트랙트
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;
import "./Auction.sol";
contract Attacker {
Auction auction;
constructor(Auction _auctionaddr) {
auction = Auction(_auctionaddr);
}
function attack() public payable {
auction.bid{value: msg.value}();
}
}
- Attack.sol은 Auction 계약의 배포된 주소를 가져와서 생성자에서 초기화하여 공격자가 Auction 계약의 함수에 접근할 수 있도록 합니다.
- attack함수는 Auction 계약의 bid 함수를 호출하여 입찰을 합니다.
❓예기치 않은 Revert 공격 순서
- 사용자 1이 3 Ether로 입찰하고, 그 결과 사용자 1이 frontRunner가 됩니다.
- 사용자 2가 5 Ether로 입찰하고, 그 결과 사용자 2가 frontRunner가 되고 사용자 1에게 3 Ether가 환불됩니다.
- 공격자가 스마트 컨트랙트의 attack 함수를 호출하고 7 Ether로 입찰합니다. 그 결과 공격자 스마트 컨트랙트가 frontRunner 가 되고 사용자 2에게 5 Ether가 환불됩니다.
- 이후 사용자 3이 bid 함수를 호출하여 신규 입찰을 진행할 시, 공격자 스마트 컨트랙트에 대한 환불이 실패합니다. 이는 Attacker 스마트 컨트랙트가 receive 또는 fallback 함수를 구현하지 않아 Ether를 받을 수 없기 때문입니다. 이로 인해 이더 전송 함수인 call, send, transfer 함수를 호출하면 require 문으로 인해 예외 또는 예기치 않은 revert가 발생하여 실행이 중지됩니다.
🤔 해결책
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract Auction is ReentrancyGuard {
address frontRunner;
uint256 highestBid;
mapping(address => uint) public balances;
function bid() public payable {
require(msg.value > highestBid, "Need to be higher than highest bid");
// 이전 입찰자 정보 업데이트
balances[frontRunner] += highestBid;
// 새로운 입찰자 정보 업데이트
frontRunner = msg.sender;
highestBid = msg.value;
}
function withdraw() public nonReentrant {
require(msg.sender != frontRunner, "Current frontRunner cannot withdraw");
uint amount = balances[msg.sender];
balances[msg.sender] = 0;
(bool sent, ) = msg.sender.call{value: amount}("");
require(sent, "Failed to send Ether");
}
}
이전 코드에서는 푸시 모델을 사용하여 기존 입찰자에게 Ether를 직접 전송하고 있었습니다. 그러나 이로 인해 만약 이전 입찰자의 컨트랙트나 계정으로 Ether 전송이 실패할 경우, revert가 발생하여 이후의 모든 입찰이 막히는 상황이 발생할 수 있습니다.
새로운 해결책에서는 풀 모델로 변경되었습니다. 입찰자의 정보를 더 이상 직접 전송하지 않고, 대신 입찰자의 계정에 대한 입찰 금액을 매핑하는 방식으로 업데이트합니다. 즉, 입찰자는 자신의 입찰 금액을 스스로 인출할 수 있게 되었습니다.
🤔 푸시 모델 Vs 풀 모델
🔵 푸시 모델 (Push Model)
푸시 모델은 기존 입찰자에게 Ether나 자산을 직접 전송하는 방식을 말합니다. Auction 스마트 컨트랙트의 기존 코드에서는 푸시 모델을 사용하여 입찰자가 새로운 최고 입찰가로 업데이트되면, 즉시 이전 입찰자에게 그동안의 입찰 금액을 전송하고 있습니다.
푸시 모델은 간단하고 직관적이지만, 이전 입찰자가 스마트 컨트랙트나 계정의 코드를 실행하는 과정에서 에러가 발생하거나 트랜잭션이 실패하여, revert가 발생할 수 있습니다.
그러면 해당 트랜잭션 이후의 모든 입찰 처리가 중단되고, 더 이상 입찰자가 업데이트되지 않습니다. 이런 경우, 새로운 최고 입찰가로 변경되더라도 이전 입찰자에게 자산을 보내지 못하므로 기존 입찰자의 자산이 잠길 수 있습니다.
🔵 풀 모델 (Pull Model)
풀 모델은 자산 또는 정보를 직접 전송하는 대신, 입찰자의 계정에 대한 정보를 스마트 컨트랙트 내에 매핑하는 방식을 말합니다.
Auction 스마트 컨트랙트의 새로운 코드에서는 풀 모델로 변경되었습니다. 입찰자의 정보는 더 이상 직접 전송되지 않고, 대신 입찰자의 계정에 대한 입찰 금액을 매핑하여 업데이트합니다.
이 방식에서는 기존 입찰자의 자산을 스마트 컨트랙트에서 직접 처리하는 것이 아니라, 입찰자가 스스로 자신의 계정에서 입찰 금액을 인출할 수 있도록 합니다. 즉, 입찰자는 컨트랙트에 저장된 자신의 입찰 금액을 인출하고자 할 때, 특정 함수를 호출하여 자산을 가져옵니다.
❗️블록 가스 제한
블록 가스 제한의 경우, 트랜잭션이 사용 가능한 최대한도보다 높은 가스 한도를 가지고 있는 경우, 트랜잭션이 실패합니다. 이러한 트랜잭션이 실패하면, 특히 소유자에게 환불을 할 때 루프를 사용하는 경우 실행이 중단되어 모든 환불이 막힙니다.
😭 블록 가스 제한을 초과할 수 있는 for 루프의 예시
address[] private refundAddresses;
mapping (address => uint) public refunds;
function refundAll() external onlyOwner {
// 참가한 주소 수에 기반한 알 수 없는 길이 반복
for(uint i; i < refundAddresses.length; i++) {
// 한 번 실패시 모든 자금의 출금이 정지
require(refundAddresses[i].send(refunds[refundAddresses[i]]))
}
}
🤔 해결책
function withdraw() public nonReentrant {
require(msg.sender != frontRunner, "Current frontRunner cannot withdraw");
uint amount = balances[msg.sender];
balances[msg.sender] = 0;
(bool sent, ) = msg.sender.call{value: amount}("");
require(sent, "Failed to send Ether");
}
예기치 못한 Revert 공격과 마찬가지로, refundAll 함수는 withdraw 함수로 대체되며, 한 번에 한 사용자만 인출할 수 있도록 합니다.
❗️블록 스터핑
공격자가 다른 트랜잭션들이 블록에 포함되는 것을 방해하는 공격입니다. 이러한 방식으로 공격자는 네트워크의 트랜잭션 처리를 방해하거나 비용을 높이는 등의 악의적인 목적을 달성할 수 있습니다.
✍️ 블록 스터핑의 특징
1️⃣ 컴퓨팅 작업 부하
공격자는 블록에 포함되는 트랜잭션을 매우 복잡하게 만들거나 계산적으로 매우 비용이 큰 트랜잭션을 생성합니다. 이러한 트랜잭션이 블록체인 네트워크의 노드들에게 처리되기 위해서는 많은 계산 작업이 필요하므로 블록 생성 속도가 느려지고, 블록이 가득 차는 현상이 발생할 수 있습니다.
2️⃣ 높은 가스 비용
공격자는 자신의 트랜잭션에 매우 높은 가스 비용을 설정하여 마이너들이 해당 트랜잭션을 우선적으로 처리하도록 유도합니다. 이로 인해 다른 일반적인 트랜잭션이 가스비가 높아져서 처리가 지연되거나 거부되는 상황이 발생할 수 있습니다.
3️⃣ 네트워크 지연
블록 스터핑으로 인해 블록체인 네트워크가 지연되면서 사용자들의 트랜잭션 처리 속도가 저하될 수 있습니다.
주로 블록 스터핑은 PoW(Proof-of-Work) 블록체인에서 발생합니다. PoW 블록체인은 채굴자들이 수학적 퍼즐을 푸는 작업을 수행하여 새로운 블록을 생성합니다. 따라서 공격자가 블록을 가득 채우거나 높은 가스 비용을 설정하여 블록을 혼잡하게 만들 수 있습니다.