[Hacking Series] #05 Denial of Service

Beomki Kim
Decipher Media |디사이퍼 미디어
15 min readJan 7, 2023
Smart Contract Hacking [source]

본 게시글은 이더리움 스마트 컨트랙트 해킹 유형을 분석한 시리즈 중 5편입니다. 이번 편에서는 GovernMental 프로젝트 사태로 보는 Web3에서의 DOS 공격과 이를 방지하기 위한 방법에 대해 분석합니다.

Author : Beomki Kim
Reviewer : Yohan Lim

서울대학교 블록체인 학회 디사이퍼에서 스마트 컨트랙트 해킹에 대한 글을 시리즈로 연재합니다. 본 글은 해킹 시리즈의 5편으로, 다른 편을 읽고 싶으시다면 아래의 리스트를 확인해주십시오.

[Hacking Series]

1편: Intro
2편: Unsafe Delegatecall
3편: Front Running
4편: Signature Replay
5편: Denial of Service
6편: Arithmetic Overflow / Underflow & Conclusion

목차

  1. DOS 공격의 의미 및 Web2 에서의 예시
  2. Web3 에서의 DOS 공격
  3. 취약점 및 보완방법

DOS 공격의 의미 및 Web2 에서의 예시

Denial-of-Service (DOS) 공격이란, 특정 기계나 네트워크에 사용자들이 접속하지 못 하게 막는 것입니다. 대량의 트래픽을 보내 해당 네트워크를 마비시키고 다른 정상적인 유저들의 트래픽을 막아버립니다. 하지만 DOS 공격은 한 위치에서 실행하는 거라 기존 IT 서비스에서는 막기가 쉬웠습니다. 그래서 이번 단락에서는 뉴스에서도 많이 들어본 디도스 (DDOS) 공격에 대해 다루려고 합니다.

DDOS 는 “Distributed Denial Of Service” 라는 뜻으로, 여러 대의 컴퓨터를 통해 여러 위치에서 동시에 DOS 공격을 한다는 의미입니다. 여기서 여러 대의 컴퓨터도 공격자들이 따로 마련하는 것이 아니라 다른 컴퓨터들을 멀웨어나 취약점을 이용해 악성 감염해 봇처럼 만들어버립니다. 감염당한 컴퓨터들은 이 사실을 알아차리기가 어렵고 공격자들이 쉽게 이용할 수 있게 됩니다. 이러한 여러 대의 컴퓨터를 “봇넷” 이라고 하고, 공격자들은 봇넷을 이용함으로써 공격 규모를 증폭시킬 수 있을 뿐더러 DDOS 공격을 당한 네트워크가 공격자들을 알아내기 더 힘들게 합니다.

DDOS 공격은 굉장히 빈번하게 발생하고 있고, 그 규모도 굉장히 큽니다. 지금까지 발생한 가장 큰 규모의 공격은 2017년 9월에 Google 서비스를 공격한 것으로 초당 2.54테라비트의 속도의 트래픽이었습니다. 아마존과 깃허브 등 많은 유저들을 보유한 서비스들도 DDOS 공격에서 자유롭지 않았습니다. 이렇듯 다양한 방식으로 다양한 타겟에 DDOS 공격이 이루어지고 있는데 대표적으로 증폭, 프로토콜, 애플리케이션 레이어 공격으로 분류할 수 있습니다.

DDOS 증폭 공격

증폭 공격의 경우, 트래픽의 수를 증가시키는 용량 기반의 공격입니다. 이를 위해서는 감염된 여러 대의 봇넷이 필요한데 이 방법이 매우 다양했습니다. 2015년 깃허브 공격에서는 중국에서 가장 인기 있는 검색 엔진인 바이두에 접속한 모든 사람들의 브라우저에 악성 JavaScript 코드를 삽입하였고, 감염된 브라우저는 자동으로 Github 에 HTTP 요청을 보내게 되었습니다.

DDOS 프로토콜 공격

프로토콜 공격의 경우, 현재의 TCP(Transmission Control Protocol) 접속방식을 악용한 것인데 서버에 접속하기 위해선 3방향 Handshake가 필요합니다. TCP 는 클라이언트가 서버에 접속하겠다는 SYN(Synchronization) 패킷을 전송하면 서버는 SYN-ACK(Acknowldegment) 를 반환하고, 클라이언트가 최종적으로 ACK 패킷을 다시 보내야 Handshake가 완성이 됩니다. 하지만 이 때, 마지막 ACK 패킷을 보내지 않고 서버가 계속 기다리게만 함으로써 리소스를 소비하는 것입니다.

애플리케이션 레이어 공격의 경우, 네트워크 전체보다 특정 앱을 타겟으로 일어나는 것입니다. 단순히 HTTP Request 를 많이 보내는 방식으로 과부하를 만드는데 합법적으로 API 를 쓰고 있는 사람인지 공격을 하려는 사람인지 구분하기 어려운 문제를 악용하는 것입니다.

그렇다면 DDOS 공격을 막는 방법은 무엇일까요?

먼저 각 공격의 특성에 따른 데이터를 측정하고 모니터링 하고 있어야 합니다. 프로토콜 공격의 경우 PPS(Packet Per Second), 애플리케이션 공격의 경우 RPS(Request Per Second) 를 주시하고 있다면 DDOS 공격을 일찍이 알아차릴 수 있을 것입니다. 다음으로는 서버를 여러 데이터센터나 클라우드에 분산하고 있어야 합니다. 모든 DDOS 공격이 서버에 트래픽을 증가시켜 새로운 요청을 처리하지 못 하게 하는 방식인데 이 때 서버가 여러 곳에 분산되어 있다면 공격도 더 어려워질 것이고, 새로운 요청들도 처리할 수 있을 것입니다.

Web3 에서의 DOS 공격

윗 단락에서 언급했듯이 DOS 와 DDOS 의 차이는 공격자의 수에 있고, 한 위치에서 공격하는 DOS 공격은 기존 웹서버에서는 막기가 쉬워 대비가 잘 되었습니다. 하지만 Web3 초창기 때는 대비가 되어 있지 않아 단순한 DOS 공격에도 취약했습니다.

블록체인에서의 가스비는 채굴자에게 보상을 위한 측면도 있지만 무분별한 트랜잭션 남발에 페널티를 줌으로써 DOS 공격을 방지하기 위함도 있었습니다. 하지만 특정 코드가 실행됐을 때 필요한 가스비가 현재 블록의 Gas Limit 을 계속 넘어가게 만들 수 있다면 해당 컨트랙트 코드는 계속 거절당할 뿐만 아니라 공격자도 가스비가 들지 않습니다.

실제로 발생했던 예시를 같이 살펴보도록 하겠습니다. ‘GovernMental’ 이라는 2016년에 처음 배포된 컨트랙트가 있었습니다. (컨트랙트 주소 : https://etherscan.io/address/0xf45717552f12ef7cb65e95476f217ea008167ae3#code) 이 프로젝트는 폰지 사기의 구조를 게임화 시킨 거였습니다.

컨트랙트 코드에서 핵심인 함수만 같이 살펴봄으로써 작동방식을 이해해보도록 하겠습니다.

function lendGovernmentMoney(address buddy) returns (bool) {
uint amount = msg.value;
// 12시간 지났는지 확인
if (lastTimeOfNewCredit + TWELVE_HOURS < block.timestamp) {
// Return money to sender
msg.sender.send(amount);
// 12시간이 지났으면 마지막으로 빌려간 사람에게 자금 모두 전송
creditorAddresses[creditorAddresses.length - 1].send(profitFromCrash);
corruptElite.send(this.balance);
// 컨트랙트 정보 초기화
lastCreditorPayedOut = 0;
lastTimeOfNewCredit = block.timestamp;
profitFromCrash = 0;
creditorAddresses = new address[](0);
creditorAmounts = new uint[](0);
round += 1;
return false;
}
else {
if (amount >= 10 ** 18) {
// 12시간이 안 지났으면 시간 초기화
lastTimeOfNewCredit = block.timestamp;
// 채권자 목록에 새로운 사람 추가
creditorAddresses.push(msg.sender);
creditorAmounts.push(amount * 110 / 100);
// 5% 수수료 징수
corruptElite.send(amount * 5/100);
// 또 다른 5% 는 마지막 상금을 위해 적립해 나간다.
if (profitFromCrash < 10000 * 10**18) {
profitFromCrash += amount * 5/100;
}
// 나머지 90% 의 돈은 이전 채무 상환하는 데 사용된다.
if (creditorAmounts[lastCreditorPayedOut] <= address(this).balance - profitFromCrash) {
creditorAddresses[lastCreditorPayedOut].send(creditorAmounts[lastCreditorPayedOut]);
buddies[creditorAddresses[lastCreditorPayedOut]] -= creditorAmounts[lastCreditorPayedOut];
lastCreditorPayedOut += 1;
}
return true;
}
else {
msg.sender.send(amount);
return false;
}
}
}

유저들은 Government 의 ETH 를 10% 이자율로 빌려갈 수 있었고, 빌려갈 때마다 lastTimeOfNewCredit 이 초기화 됩니다. 그러다 아무도 안 빌려간 채 12시간이 지나면 그 동안 Jackpot 에 쌓였던 ETH 를 마지막으로 빌려갔던 사람이 다 가져가는 형식이었습니다. 폰지가 터지는 순간을 잭팟이 터지는 순간으로 비유한 참신한 프로젝트였습니다.

많은 유저들이 잭팟의 주인공이 될 것이라는 기대로 꾸준히 많은 ETH 를 빌려갔습니다. 잭팟의 순간을 대비해 이 컨트랙트는 마지막 거래 주소만 저장해두는 대신 모든 기록을 creditorAddresses 라는 array 에 저장을 해두었습니다. 그러다 실제로 12시간이 지나고 처음으로 잭팟이 터지게 된 시점이 있었는데 다음 라운드로 넘어가기 위해 정보를 초기화할 때, 아래와 같은 방식을 사용했습니다.

if (lastTimeOfNewCredit + TWELVE_HOURS < block.timestamp) {
...
creditorAddresses = new address[](0);
creditorAmounts = new uint[](0);
...
}

위의 방식으로 초기화하는 것은 두 Array 내의 각각의 데이터가 저장된 위치에 하나씩 방문해 지워나가는 방식입니다. 그런데 많은 사람들이 참여했다 보니 storage 에 저장되었던 creditorAddresses, creditorAmounts 의 용량은 매우 컸었고, 이 과정이 굉장히 많은 가스비를 필요로 했습니다. 그 당시에 잭팟의 수혜자는 총 1100 ETH 를 받을 수 있었는데 이 트랜잭션을 성공시키기 위해 필요했던 가스비가 무려 5,057,945 Gas 였습니다. 5M Gas 는 당시 기준 0.25ETH 밖에 되지 않았지만 한 transaction 에 최대로 가능했던 가스비 자체가 4,712,388 여서 이 트랜잭션은 계속 실패되었습니다. (실패 트랜잭션 주소) 나중에 가스비가 블록 가스 리밋 아래로 내려왔을 때 성공 (성공 트랜잭션 주소) 하긴 했지만 그동안 의도치 않게 컨트랙트 스스로 DOS 공격을 가해서 시스템을 마비시키게 된 셈이었습니다.

취약점 및 보완 방법

위 실제 사례는 누군가의 공격이 아닌 컨트랙트 자체의 결함이었지만 다른 컨트랙트들도 허점이 있으면 충분히 다른 사람의 공격에 의해서 발생할 가능성도 높습니다. 예를 들어 아래와 같이 0.01ETH 를 내면 NFT 화이트리스트에 등록할 수 있게 해주고 나중에 이 사람들에게 서비스 시작 시 한번에 민팅해서 배분해주는 컨트랙트를 만들었다고 합시다.

contract WhitelistNFT is Ownable{
address[] whitelist;

function register() public payable {
require(msg.value >= 0.01 eth);
// 계속해서 화이트리스트에 유저들을 등록시켜 나간다.
whitelist.push(msg.sender);
}

function mintAll() public onlyOwner {
for(uint i = 0; i < whitelist.length; i++) {
// 여기 mint(to, itemId) 함수를 실행하면
// 새로운 아이템을 to 주소에 민팅해주고 itemId 인덱스를 설정합니다.
mint(whitelist[i], i);
}
}
}

위 컨트랙트는 문제 없을까요?

언뜻 보면 문제 없는 것 같습니다. 하지만 이런 공격자가 있다면 어떨까요? 소정의 가스비와 ETH 를 소모하면서 whitelist 에 굉장히 많은 유저를 등록하는 것입니다. 그렇게 된다면 나중에 ‘mintAll()’ 함수가 실행될 때 GovernMental 예시와 비슷한 일이 벌어지게 됩니다. whitelist 에 저장되어 있던 모든 주소들을 for loop 을 돌면서 민팅해주는 방식이기 때문에 whitelist 의 크기가 매우 크다면 DOS 증폭 공격처럼 많은 요청이 한꺼번에 컨트랙트에 들어오게 됩니다. 따라서 엄청난 가스비가 들 수 밖에 없고 영구히 트랜잭션이 거절 당하게 됩니다.

그렇다면 어떻게 보완할 수 있을까요?

독자분들도 느끼셨겠지만, 컨트랙트 storage 에 저장된 모든 데이터를 loop 도는 건 굉장히 위험한 일입니다. 따라서 이를 지양하는 방법으로 개발해야 합니다. 즉, 이 경우에는 서비스 시작 시점에 되면 해당 유저들이 직접 claim 을 해가는 방식을 사용하면 됩니다.

위의 예시를 조금 더 변형해서 owner가 register 가 종료되는 시점을 정할 수 있다고 해봅시다. 그리고 그 시점이 되어야 유저들이 claim 을 할 수 있다고 하면 아래와 같이 함수를 추가할 수 있을 것 같습니다.

contract WhitelistNFT is Ownable{
address[] whitelist;
bool public isEnded = false;

function register() public payable {
require(!isEnded);
require(msg.value >= 0.01 eth);
whitelist.push(msg.sender);
}

// 컨트랙트 오너가 화이트리스트 등록 종료 시점을 설정합니다.
function endRegister() public onlyOnwer {
isEnded = true;
}

function claim() public {
require(isEnded);
// 여기 checkWhiteList(address) 함수는 msg.sender가 화이트리스트 안에 있는지 확인합니다.
require(checkWhiteList(msg.sender));
mint(msg.sender, itemId);
}

}

하지만 이 또한 또 다른 문제가 있습니다. contract owner 의 private key 가 분실되거나 해킹당한다면 전자의 경우, isEnded로 바꾸는 것을 평생 하지 못하게 되고 유저들도 claim을 할 수 없는 사용 불능상태가 됩니다. 이는 TCP 통신이 계속 이루어지지 못하게 되는 DOS 프로토콜 공격과 형식이 유사합니다. 후자의 경우, 키를 탈취한 해커가 DOS 증폭 공격을 할 수 있습니다.

이 문제는 또 어떻게 보완할 수 있을까요?

일단 private key 해킹 위험을 방지하기 위해 multi sig 컨트랙트가 키를 분산해서 트랜잭션에 서명하는 것처럼 owner를 관리해서 위험을 줄이는 방법이 가능합니다. 분실 위험을 방지하는 방법은 onlyOwner 라는 조건만 사용하는 대신 여분의 안전장치 조건을 만들어두는 것입니다. 예를 들어 whitelist 가 특정 인원이 다 찼을 때나 특정 시간이 넘어갔을 때 자동으로 실행되게 하면 됩니다. 이렇게 되면 DOS 프로토콜 공격에서처럼 통신이 계속 성사되지 않는 일은 없을 것입니다. 이를 코드에 적용한다면, onlyOwner modifier 를 아래와 같이 변경해 사용하는 것입니다.

modifier onlyOwnerOrWhitelistMax {
// 화이트리스트 배열 길이가 특정 수 이상 되었을 때
require(msg.sender == owner || whitelist.length > max_whitelist_length);
_;
}

modifier onlyOwnerOrBlockTimeOut {
// 블록 넘버가 일정 시간 지났을 때
require(msg.sender == owner || block.number > unlockBlockNumber);
_;
}

지금까지 Smart Contract 에서 DOS 공격의 예시와 보완방법에 대해 살펴보았습니다. Web3 에서의 DOS는 가스비의 존재 덕분에 Web2 처럼 큰 규모의 트래픽 공격으로 발생하는 일은 거의 없습니다. 위에서 살펴봤던 실제 사례나 예시 코드도 대부분 스마트 컨트랙트 코드의 허점으로 일어날 수 있었던 것입니다. 따라서 이번 기회를 통해 어떠한 부분들을 조심해야 하는지만 인지한다면 Web2에서 DDOS 방어 방법보다 훨씬 쉽게 예방할 수 있는 문제인 것입니다.

--

--