rkm0959's profile image

rkm0959

November 20, 2021 00:00

Smart Contract Vulnerabilities

blockchain

서론

최근 스마트 컨트랙트 보안에 대해 공부하게 되었습니다. 블록체인 위에 있는 여러 스마트 컨트랙트들은 대부분 NFT나 ERC20 토큰, 또는 ETH, AVAX 등 블록체인 위의 기초 화폐를 다루는 만큼 그 보안이 매우 중요하게 여겨지고 있습니다. DeFi (Decentralized Finance, 탈중앙화 금융) 서비스를 개발하는 회사들은 여러 보안 전문 회사에 Security Audit을 받으면서 보안성을 강화하려고 하지만, 모든 실수를 완벽하게 막을 수는 없기 때문에 여러 해킹사건이 발생하고는 합니다. 이 글에서는 스마트 컨트랙트 위에서 발생하는 공격들에 몇 가지에 대해서 간략하게 알아보도록 하겠습니다.

ETH DAO Attack

2016년 4월 이더리움 DAO (Decentralized Autonomous Organization)가 출범하면서 많은 관심과 돈이 쏠리기 시작했습니다. 하지만 얼마 지나지 않아 6월에 DAO에 속한 스마트 컨트랙트에 보안 취약점이 발견되었고, 이를 수정하려고 하는 찰나 6월 17일에 해킹 공격을 당해 약 360만 개의 ETH를 뺐기게 되었습니다. 당시 이더리움 관계자들은 이 해킹 공격을 없던 것으로 되돌리는 hard fork를 하기로 결정했고, 이에 반발한 일부 사람들은 기존 이더리움 블록체인을 이어가는 이더리움 클래식을 만들게 됩니다. 여기서 이더리움과 이더리움 클래식이 나뉘게 된 것입니다.

당시 공격을 당한 방식은, 지금은 잘 알려진 취약점 중 하나인 재진입 (Reentrancy) 공격입니다. https://swcregistry.io/docs/SWC-107 의 예시를 가져와서 설명해보겠습니다. 사전지식을 최소화하기 위해 설명을 붙였습니다.

/*
 * @source: http://blockchain.unica.it/projects/ethereum-survey/attacks.html#simpledao
 * @author: Atzei N., Bartoletti M., Cimoli T
 * Modified by Josselin Feist
 */
pragma solidity 0.4.24;

// msg.value = amount of ETH that was sent
// msg.sender = the address which sent this transaction

contract SimpleDAO {
  mapping (address => uint) public credit;
    
  function donate(address to) payable public{
    credit[to] += msg.value;
  }
    
  function withdraw(uint amount) public{
    if (credit[msg.sender]>= amount) {
      // send amount ETH to msg.sender, fallback() function is called if msg.sender is a contract
      require(msg.sender.call.value(amount)()); 
      credit[msg.sender]-=amount;
    }
  }  

  function queryCredit(address to) view public returns(uint){
    return credit[to];
  }
}

donate 함수를 호출하면 ETH를 기부할 수 있고, 대신 credit이 그 수량만큼 증가합니다. 나중에 withdraw를 호출하면, 원하는만큼의 ETH가 호출한 사람의 주소로 다시 돌아오고, credit이 그 수량만큼 감소합니다. 겉으로 보면 credit의 수량이 원하는 amount 이상임도 확인했으니, 별 문제가 없는 코드같아 보입니다. 하지만 여기서 간과한 것은, amount만큼의 ETH를 보내기 위해서 msg.sender.call.value(amount)()를 호출했을 때, msg.senderfallback 함수가 호출이 된다는 것입니다. fallback 함수에서 한 번 더 withdraw를 호출한다면 어떻게 될까요? 아직 credit의 값에 amount가 빠지기 전이기 때문에, 한 번 더 msg.sender.call.value(amount)()가 호출됩니다. 즉, ETH를 두 번 빼낼 수 있습니다. 이렇게 코드에 한 번 더 진입하는 방식으로 하는 공격을 재진입 공격이라고 부릅니다. 이를 막으려면, 이 예시의 경우에서는 msg.sender.call.value(amount)() 이전에 credit 값에 amount를 빼주면 됩니다. 이러한 나름 단순한 공격으로 360만 ETH가 빼았겼다는 생각을 해보면 어마어마하다는 생각이 듭니다. 지금 가치로 생각하면 더욱 그렇죠.

이 외에도 순수하게 코드로 터지는 공격들이 많습니다. 이는 위에 올려놓은 swcregistry를 참고하시면 많이 공부하실 수 있습니다.

Price Manipulation

금융과 관련된 스마트 컨트랙트가 많은 만큼, 금융적인 부분이 섞인 공격이 많습니다. 대표적으로 가격을 조작하는 과정이 섞인 공격들이 상당히 많습니다. 가격 조작을 어떤 방식으로 하는지, 어떻게 활용하는지 간략하게 알아봅시다.

  • 금융적인 부분이 섞인 공격인 만큼 기본적인 금융시장에 대한 이해도가 필요합니다

Decentralized Lending

DeFi에는 탈중앙화된 대출 플랫폼이 있습니다. 대표적으로, AAVE(https://aave.com/)에는 현재 30조가 넘는 자산이 다뤄지고 있습니다.

이러한 대출 플랫폼이 작동하는 방식은, 우리가 익숙한 담보대출입니다. 예를 들어, 1ETH가 4500달러라고 합시다. 이더리움의 LTV는 (Loan-To-Value : 담보인정비율) 80%이므로 (이는 AAVE의 Governance에서 정합니다) 1ETH를 담보로 예치하면 3600달러에 해당하는 수량의 토큰들을 빌려갈 수 있습니다. 예를 들어, 1달러로 가치가 페깅되어있는 (고정되어있는) USDT를 최대 3600개 빌릴 수 있습니다.

담보대출의 장점은 전통금융시장과 마찬가지로 무조건 갚는 게 이득이란 점입니다. 제가 3600 USDT를 빌린 것을 가지고 도망가면, 애초에 예치해놓은 4500달러 어치의 담보를 돌려받을 수 없기 때문에 제가 손해를 보겠죠. 룰을 어기면 경제적으로 손해를 보도록 하는 것이 설계의 기본입니다.

하지만 ETH는 가격 변동이 심한 자산입니다. 만약 제가 1ETH를 예치하고 3600 USDT를 빌렸다가, ETH의 가격이 떨어진다면 어떻게 될까요? AAVE의 경우, ETH에 대한 “청산 한계점”이 85%이므로, ETH의 가치가 3600/0.85 = 4286달러 이하가 되는 순간부터 청산이 시작됩니다. 이 경우, 다른 사람들이 제 USDT 빚을 대신 갚아주고, 이에 대한 보상으로 제 담보였던 ETH를 시장가격보다 더 싼 값에 가져가게 됩니다. 이 과정 역시, 제가 넣은 담보의 가치가 제가 빌려간 값보다 크기 때문에 가능한 것입니다.

정리하자면 ETH를 담보로 하는 경우

  • 대출 가치 <= 0.8 * 담보 가치이도록 빌릴 수 있음
  • 대출 가치 >= 0.85 * 담보 가치면 그때부터 청산 가능
  • 대출 가치 >= 담보 가치면 대출된 것을 가지고 도망가도 이득 (공격 가능)

정상적인 상황이면, 대출 가치가 담보보다 큰 경우가 되기 전에 사람들이 청산을 해주기 때문에 문제가 발생하지 않습니다. 하지만 제가 잠시 가격을 조작한다면 어떻게 될까요?

현재 ETH = 4500달러인 상황에서, AAVE가 순간적으로 ETH = 8000달러라고 생각하게 만들 수 있다고 가정해봅시다. 그러면 1ETH를 넣고 6400달러를 빼낸다음 그대로 가만히 있으면 AAVE 입장에서는 정상적인 대출입니다. 하지만 실제로 저는 6400 - 4500 = 1900 달러를 얻을 수 있고, AAVE가 정신을 차리고 ETH = 4500 달러임을 알게 되어도 이미 대출 가치가 담보 가치를 훨씬 넘어섰기 때문에 더 이상 손을 쓸 수 없게 됩니다.

물론, 이를 제외하고도 가격 조작을 활용하여 공격을 하는 방법은 많을 겁니다.

이제 가격 조작을 어떻게 하는지 알아봅시다. 애초에, 스마트 컨트랙트가 가격을 어떻게 알까요?

화이트햇 samczsun은 (https://samczsun.com/taking-undercollateralized-loans-for-fun-and-for-profit/)에서 대표적인 가격 계산 오라클 (price oracle) 설계 방법을 설명합니다. 참고로 저 글은 다양한 공격에 대해서도 설명이 잘 되어있으니 읽어보시면 좋습니다.

  • 체인 밖에서 중앙화된 방식 : DeFi 서비스를 만드는 회사가 직접 price oracle contract를 올리고, 정기적으로 가격 데이터를 체인 밖에서 가져와 직접 체인에 떠먹여주는 방식입니다. 이 경우 전적으로 회사를 믿어야합니다.
  • 체인 밖에서 탈중앙화된 방식 : 첫 번째 방식을 사용하되, 여러 개의 소스를 활용하여 하나의 소스가 잘못되더라도 정상적으로 작동되도록 설계합니다.
  • 체인 위에서 중앙화된 방식 : “체인 위의 소스”를 사용하는데, 권한이 있는 사람만 업데이트를 할 수 있도록 강제하는 방식입니다.
  • 체인 위에서 탈중앙화된 방식 : “체인 위의 소스”를 사용하는데, 권한이 없어도 누구나 업데이트를 해서 가격을 받아올 수 있도록 하는 방식입니다.
  • 고정가격 : USDT, USDC, DAI, sUSD 등은 전부 1달러에 가격이 맞춰져 있으니, 이들은 고정가격으로 취급할 수 있습니다. 참고로 이러한 코인을 stablecoin이라고 합니다.

가격 조작의 대표적인 경우는 체인 위에서 중앙화된 방식을 사용하는 경우입니다. 보통 이러한 “체인 위의 소스”는 DEX (Decentralized EXchange) 를 의미합니다. 가장 유명하고 많이 쓰이는 Uniswap의 예시를 들어 DEX의 작동원리를 설명해보겠습니다.

ETH와 USDT를 교환하고자 합니다. 이를 위해서, 사람들은 “ETH/USDT 유동성 풀”이란 것을 만들어서 거기에 ETH와 USDT를 각각이 같은 가치를 가지도록 넣습니다. 즉, ETH가 A개, USDT가 B개가 유동성 풀에 있다면 1ETH = B/A USDT가 되는 것입니다. 사람들이 할 수 있는 행동은 다음 두 가지입니다.

  • 유동성 공급 (Liquidity Provider) : 이 유동성 풀에 ETH와 USDT를 더 넣습니다. 이때, 두 자산을 각각이 같은 가치를 가지도록 넣습니다. 이는 다른 말로 하면, ETH와 USDT 개수의 비율이 일정하도록 넣습니다.
  • 교환 (Swap) : ETH를 넣고 USDT를 받거나, USDT를 넣고 ETH를 받을 수 있습니다. 이때, 유동성 풀의 ETH의 개수와 USDT 개수의 곱이 일정하도록 유지합니다. 실제로는 수수료가 붙어서 계산이 복잡해지지만, 기본적인 원리는 이렇습니다.

생각해보면, ETH를 USDT로 교환하고자 하면 유동성 풀에서 ETH 개수가 증가하고 USDT 개수가 감소하는데, 이러면 ETH의 가치가 떨어집니다. 이는 ETH를 팔고자 하는 사람이 많다는 사실과 일맥상통하니, 어느 정도 말이 되는 구조라고 볼 수 있습니다.

만약 현재 X라는 자산과 Y라는 자산이 한 유동성 풀에 있는데, 1X = 1Y라고 합시다. 유동성 풀에 각각이 100만 개씩 있다고 합시다. 만약 제가 100만 X를 가지고 와서 Y로 바꾸려고 한다면

  • 100만 * 100만 = 200만 * 50만
  • 유동성 풀은 X 200만 개, Y 50만 개
  • 제가 돌려받는 Y의 개수는 50만 개

가 됩니다. 이제 유동성 풀만 보면 순식간에 1Y = 4X가 됩니다. 비율이 4배 바뀌었네요. 이것이 유동성 풀의 무서운 점입니다. 엄청난 물량이 있으면 이걸로 가격 조작이 됩니다. 정상적인 환경에서는, 만약 어떤 사람이 이런 행위를 했다면 다음과 같은 일이 벌어집니다.

  • 이 유동성 풀을 제외한 다른 곳에서 1Y = 1X 수준으로 거래를 할 수 있는 곳이 있습니다. (중앙화 거래소도 있고, Sushiswap 등 다른 유동성 풀도 있겠죠)
  • 1Y를 보유하고 있는 사람은 조작된 유동성 풀에서 Swap, 4X를 받습니다.
  • 이제 4X 중 1X를 가져다가 조작되지 않은 풀에서 Swap, 1Y를 받습니다.
  • 이러면 저는 공짜로 3X를 벌었습니다. 이를 차익거래 (arbitrage) 라고 합니다.

하지만 스마트 컨트랙트 안에서 모든 것을 한 방에 터뜨린다면 이야기가 달라집니다. 이더리움은 transaction을 순서대로 처리하기 때문에, (가격 조작 -> 공격 -> 가격 정상화)라는 일련의 과정을 아무 방해없이 한 번에 처리할 수도 있습니다. 물량만 있으면 공격이 가능할 수도 있다는 것이죠. 그래서 사람들은 price oracle을 디자인 할 때 지금 당장의 가격이 아니라 최근 15분 간의 가격 평균 (TWAP) 등을 사용합니다. 이렇게 설계를 하면, 가격 조작의 난이도가 훨씬 올라가기 때문이죠.

  • 가격 조작이 불가능하지는 않습니다. 예를 들어, 가격 조작을 하고 15분 동안 가스비를 높인 상태에서 transaction을 미친듯이 날리면 DDoS와 비슷한 효과를 낼 수 있어 15분 간 아무도 아무것도 하지 못하게 할 수 있습니다. 하지만 이렇게 하면 공격에 필요한 돈이 너무 커지겠죠?

현재 Sushiswap의 ETH/USDC 유동성 풀 (https://etherscan.io/address/0x397ff1542f962076d0bfe58ea045ffa2d347aca0)의 경우, ETH와 USDC가 각각 1800억 정도가 있습니다. 이런 유동성 풀을 조작하려면, 돈이 꽤나 들겠네요. 해커가 저런 규모의 돈이 없을 때는 공격이 불가능할까요?

Flash Loan : 1조를 움직이는 기적

AAVE에서는 Flash Loan이란 기능이 있습니다. (Flash Loan을 처음 디자인한 사람은 누구인지 저도 잘 모르는데, 일단 AAVE에 쌓여있는 돈이 많아서 Flash Loan 규모도 큼직큼직하게 할 수 있습니다) 아까 전에도 말씀드렸지만, 스마트 컨트랙트를 사용하면 여러 가지 함수 호출을 한 트랜잭션에 한방에 할 수 있습니다. 그래서 다음과 같은 기능이 존재할 수 있습니다.

  • AAVE에서 일단 담보가 없이 아무 자산이나 있는 만큼 빌립니다
  • 빌려간 사람이 그 transaction 안에서 그 자산을 가지고 아무거나 하게 해줍니다
  • 단, 그 transaction이 끝날 때 빌려간 양 + 수수료를 지불해야 합니다.
  • 만약 지불에 실패할 경우, transaction 전체가 취소됩니다.

이렇게 하면 AAVE 입장에서는 무조건 빌려간 것을 받을 수 있으니 위험이 없습니다. 수수료도 받아가니 좋죠. 빌려간 사람 입장에서는 담보 없이 몇천억, 심하면 조 단위로 돈을 빌릴 수 있으니 좋습니다. 다만 수수료가 빌려간 양에 비례해서 나오기 때문에 감당할 수 있을만큼 해야합니다. 이러한 Flash Loan은 앞서 언급한 차익거래를 할 때 활용할 수 있습니다. 1Y만 있는 사람은 한 번에 3X만 얻을 수 있고 이를 계속 반복하느라 고생을 해야겠지만, 한 번에 수십만 Y를 Flash Loan할 수 있다면 훨씬 더 편하고 효율적으로 차익거래를 할 수 있겠죠? Flash Loan에는 여러 기능이 있지만 확실한 이익을 볼 수 있는 각이 열렸을 때 활용할 수 있습니다.

그러면 가격 조작도 쉽습니다. 필요한 만큼 Flash Loan 하면 됩니다. 다만 가격 조작으로 이익을 취하는 과정까지 전부 한 transaction에 담아야 한다는 점에 주의해야 합니다.

Flash Loan이 공격에 많이 쓰여서, PeckShield 같은 보안 기업은 트위터 등 SNS에 Flash Loan이 있으면 바로 #FlashLoanAlert라고 하면서 띄워줍니다. AAVE 입장에서 손해볼 것은 없지만, 어쨌든 무담보로 1조를 움직일 수 있다는 어마어마한 기능을 만들면 이런 일이 생기기 마련이죠.

https://www.paradigm.xyz/2020/11/so-you-want-to-use-a-price-oracle/ 도 읽어보시면 좋습니다.

여담 : Cream Finance 공격

Cream Finance가 최근 1억 3천만 달러에 가까운 돈을 잃었습니다. 사실상 사람들이 예치한 돈 전부를 잃은 것입니다. 이 공격 역시 Flash Loan을 사용한 가격 조작 공격인데, 담보로 가격 조작 난이도가 매우 낮은 자산을 허용해서 발생했습니다. 이러한 자산을 담보로 받는 것을 승인한 잘못이 크다는 지적이 많습니다. 자세한 공격 분석은 https://mudit.blog/cream-hack-analysis/ 를 참고하시면 좋을 것 같습니다.

아래의 이야기는 개인적인 생각으로 아무 누구도 대변하지 않습니다.

Cream Finance는 공격당한 역사가 꽤 많은 프로토콜입니다. “또 당했다”는 의견이 많은만큼 아쉬움이 많이 남습니다. 더 안타까운 점은 그 후속조치입니다. 먼저 해커가 돈을 돌려주면 10%를 바운티로 지급하겠다는 공지를 올렸는데, 해커가 나타나지 않자 다음 트윗을 올렸습니다.

  • https://twitter.com/CreamdotFinance/status/1459532398991851528

내용인 즉슨, Cream Finance의 Governance Token인 CREAM을 1453415개 찍어서 피해를 입은 사람들에게 분배하겠다는 내용입니다. 당시 CREAM의 가격은 약 90달러 정도였습니다. 즉, 1억 3천만 달러의 가치에 해당하는 CREAM을 찍어서 나눠주겠다는 것입니다. 대신, Cream Finance 팀에게 분배될 예정이었던 CREAM을 분배하지 않기로 결정했습니다. 즉, 팀에게 천천히 분배될 CREAM을 주지 않고, 당장 피해자들에게 한방에 분배하겠다는 결정입니다.

사실 이는 Cream Finance 팀이 피해자를 굉장히 생각해준 결정입니다. 대부분의 서비스들은 해킹 리스크가 항상 있음을 사용자에게 강조하고, 책임을 가능하면 지지 않으려고 합니다. 이런 식으로라도 피해자 보상을 하려고 한 것은 (특히 팀이 손해를 보면서) 칭찬받을만 합니다.

문제는 당시 CREAM의 시장 유통량이 70만개 언저리였습니다. 최대 공급량이 300만개가 되지 않습니다. 이러한 상황에서 CREAM을 한 번에 145만개가 쏟아진다고 생각하면, 그 결과는 예상이 가능하죠? 당연히 CREAM의 가치가 폭락하게 됩니다. 실제로 이 뉴스가 나오자마자 CREAM의 가격이 40% 폭락하게 되고, 실제로 60% 넘게 가격이 빠졌다가 지금은 조금 돌아왔습니다. CREAM만 들고 있었던 사람들은 피해를 꽤 봤을 것으로 보입니다. 어쩌면 이런 대처가 피해자 입장에서는 최선의 선택이었을 수도 있겠습니다. 어렵습니다.

결론

잠시 스마트 컨트랙트 위의 공격에 대해서 알아봤습니다. 수천억, 수조가 움직이는 곳이니만큼 보안이 중요한 것 같습니다. 특히, 스마트 컨트랙트 보안의 경우 코드 자체의 보안 뿐만 아니라 금융공학적인 부분에도 신경쓸 부분이 많아서, 더욱 재밌고 복잡하고 어려운 것 같습니다. 긴 글 읽어주셔서 감사합니다. 나중에 블록체인 관련 CTF 문제나 풀어볼 수 있으면 좋겠습니다 :)