[Hacking Series] #06 Arithmetic Underflow Attacks & Conclusion

Dean
Decipher Media |디사이퍼 미디어
18 min readFeb 5, 2023

본 게시글은 이더리움 스마트 컨트랙트 해킹 유형을 분석한 시리즈 중 마지막 편인 5편입니다. 이번 편에서는 Arithmetic Overflow & Underflow Attack의 개념과 작동방식을 살펴보고, 그 예방법을 알아보겠습니다. 더불어 시리즈의 최종 제언을 제시하며 마무리하겠습니다.

Author : Dean
Reviewer : Yohan Lim

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

[Hacking Series]

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

목차

  1. Arithmetic Overflow & Underflow attacks의 기본개념
  2. 예시 : Proof of Weak Hands coin
  3. Solution
  4. 소결
  5. Conclusion : Contract에 “알고 사인하는” 그날까지

1. Arithmetic Overflow & Underflow attacks의 기본 개념

Arithmetic Overflow는 연산 결과의 결과값이 해당 자료형의 최댓값을 넘어서는 경우 전혀 다른 매우 작은 값을 출력하게 되는 상황을 일컫습니다. 이해를 돕기 위해 예시를 들어보겠습니다. 다음과 같은 코드를 실행한다고 가정해봅시다.

uint8 balance = 255; 
balance++;
// 255(max)값을 가진 uint8 변수에 +1을 하는 경우, Arithmetic Overflow

uint8자료형이 가질 수 있는 값은 0~255 사이의 정수입니다. 255는 이진법으로 표현할 때11111111(2)이라고 표현할 수 있습니다. 원칙 상으로 balance의 값을 1만큼 증가시키는 balance++를 통해 연산을 수행하면, 의도한 결과값은 256, 즉 100000000(2)이 출력되어야 합니다. 그러나 이는 uint8 자료형이 가질 수 있는 값의 범위를 넘어섭니다. 덧셈 연산은 낮은 자리수부터 순차적으로 더해지기 때문에, 이 경우에는 balance값이 00000000(2)으로 표시되고, 즉 256이 아닌 0이 출력되게 됩니다.

반대로 Arithmetic Underflow는 어떨까요? Overflow와는 반대로 연산의 결괏값이 자료형이 가질 수 있는 값의 범위보다 작아지면서 매우 큰 값을 갖게 되는 상태라고 생각할 수 있습니다. 위의 사례를 응용하여 생각해봅시다.

uint8 balance = 0; 
balance--;
// 0의 값을 가진 uint8을 -1하는 경우, Arithmetic Underflow 발생

위에서 살펴보았듯 uint8 자료형이 가질 수 있는 값은 0~255 사이의 정수입니다. 따라서 0에서 1을 빼는 위 연산은 -1을 출력해야 하지만 그러한 결과값은 나올 수 없습니다. 따라서 이 경우 uint8자료형의 최댓값인 255, 즉 11111111(2)가 출력됩니다.

여기까지 알아보았다면 기본적으로 Solidity에서 Overflow보다는 Underflow Attack이 더 일반적일 것임을 유추할 수 있습니다. 실제로 대부분의 공격은 Underflow를 이용해 일어났습니다. Overflow Attack이 일반적이지 않은 이유는 보통 잔고를 표시하는데 사용되는 자료형인 uint256은 2²⁵⁶- 1이 최대치이므로, 공격을 위해서는 상당히 많은 비용이 필요할 확률이 높기 때문입니다. 더불어 Underflow Attack을 통해 약간의 자본 투자로 본인의 잔고를 크게 늘리는 것이 공격자에게 더 유리한 결과를 만들어낼 것이라는 점도 쉽게 짐작할 수 있습니다.

2. 예시 : Proof of Weak Hands Coin

예시를 통해 실제로 Underflow, Overflow를 이용한 공격이 어떤 식으로 이뤄지는지 살펴보도록 하겠습니다.

PoWHcoin의 프론트 이미지. 출처 : https://imgur.com/NsA9x72

PoWHcoin (Proof of Weak Hands coin)은 2018년에 출시되었으며, Ether를 예치하고 PoWHcoin을 받은 후, 받은 PoWHcoin을 컨트랙트에 다시 반납할 때 나중에 진입한 사람들의 Ether를 통해 더 높은 보상을 받을 수 있는 전형적인 폰지를 구현한 프로젝트였습니다. 놀랍게도 당시 PoWHcoin은 선풍적인 인기를 끌며 1000ETH를 넘는 예치액을 선보지만, 이 돈을 실제로 투자자가 가져가는 일은 일어나지 않았습니다. PoWHcoin 런칭 후 단 3일만에 해커는 uint256의 최대치인 2²⁵⁶- 1개의 PoWHcoin을 획득했고, 이를 이용해 컨트랙트에서 866ETH를 출금하였습니다. 그 이후 프로젝트는 붕괴한 것은 말할 필요가 없겠죠.

해커가 사용한 방법은 바로 취약점은 바로 PoWHcoin의 ERC20 구현 중 Transfer함수에 있었습니다. 이 함수를 활용하면 Approve된 잔고만큼 다른 지갑의 PoWHcoin을 판매할 수 있었지만, 실제 잔고의 차감은 메세지를 보낸 지갑에서 이뤄지도록 잘못 구현되어 있었습니다. 이를 통해 Underflow를 유도하여 PoWHcoin의 잔고를 최대치로 바꿔버린 후, 이를 컨트랙트에 반납하여 ETH를 빼내는 Underflow Attack이 일어난 것입니다. 문제가 되었던 컨트랙트를 살펴보며 더욱 자세히 알아봅시다.

function transferFrom(address _from, address _to, uint256 _value)  public {
var _allowance = allowance[_from][msg.sender];
// msg.sender가 _from의 잔고를 변화시킬 수 있는 액수인 _allowance
if (_allowance < _value)
revert();
// tx의 _value값이 _allowance보다 크다면 revert됨
allowance[_from][msg.sender] = _allowance - _value;
transferTokens(_from, _to, _value);
// 그렇지 않은 경우, _allowance에서 _value만큼 차감이 되고, transferTokens 함수를 통해 잔고 변화
}

위 함수가 바로 PoWHcoin의 ERC20 구현에서의 transferfrom 함수입니다. 이 함수 단독으로는 즉각적인 취약점을 가지고 있지는 않습니다. 이 함수의 앞의 네 줄은 _from (이하 A지갑)의 토큰의 잔고를 변화시킬 수 있는 msg.sender(이하 B지갑)의 권한을 검증하는 단계입니다. 검증을 마친 후 마지막 줄의 transferTokens에서 B지갑은 A지갑의 잔고를 변화시킵니다.

제 잔고의 차감은 메세지를 보낸 지갑에서 이뤄지도록 잘못 구현되어 있었습니다. 이를 통해 Underflow를 유도하여 PoWHcoin의 잔고를 최대치로 바꿔버린 후, 이를 컨트랙트에 반납하여 ETH를 빼내는 Underflow Attack이 일어난 것입니다. 문제가 되었던 컨트랙트를 살펴보며 더욱 자세히 알아봅시다.

function transferTokens(address _from, address _to, uint256 _value) internal {
if (balanceOfOld[_from] < _value)
revert();
if (_to == address(this)) {
sell(_value);
//_to 주소가 컨트랙트의 CA와 일치하는 경우, 토큰을 ETH로 바꾸는 행위인 sell로 간주
} else {
int256 payoutDiff = (int256) (earningsPerShare * _value);
balanceOfOld[_from] -= _value;
balanceOfOld[_to] += _value;
payouts[_from] -= payoutDiff;
payouts[_to] += payoutDiff;
}
Transfer(_from, _to, _value);
}

transferTokens 함수는 특이한 부분이 있습니다. 바로 이 토큰 컨트랙트의 CA가 _to address에 지정되어 있다면, 토큰을 다른 지갑으로 전송하는 일반적인 transferTokens의 일반적인 용처와는 달리 토큰을 ETH와 바꾸는 행위인 sell로 간주한다는 점입니다. 그리고 문제가 되는 부분이 바로 이 sell 함수입니다.

function sell(uint256 amount) internal {
var numEthers = getEtherForTokens(amount);
// remove tokens
totalSupply -= amount;
balanceOfOld[msg.sender] -= amount;

// fix payouts and put the ethers in payout
var payoutDiff = (int256) (earningsPerShare * amount + (numEthers * PRECISION));
payouts[msg.sender] -= payoutDiff;
totalPayouts -= payoutDiff;
}

언뜻 보기에 문제가 없어보인다면, 위 상황이 msg.sender(B지갑)이 _from(A지갑)의 잔고를 바꾸는 상황임을 다시 떠올려봅시다. 위의 sell 함수에서는 _from(A지갑)에 대한 정보가 사라져 있습니다. 따라서 함수는 msg.sender(B지갑)가 판매자라고 가정하게 됩니다. 그렇다면 실제로는 A지갑에서 approve받은 A지갑의 PoWHcoin의 잔고를 B지갑이 대신 ETH로 판매하는 행위는, B지갑에서 PoWHcoin의 잔고를 차감하게 됩니다.

아! 그렇다면 underflow가 날 수 있겠습니다. B지갑이 PoWHcoin의 잔고가 없는 상태에서, PoWHcoin의 잔고가 있는 A지갑에서 Approval을 받아와 sell 함수를 호출해 c만큼의 PoWHcoin을 컨트랙트에 판매하는 경우, B지갑의 PoWHcoin 잔고는 순식간에 2^256-c가 될 것입니다. 그리고 그것이 바로 실제로 공격자가 행한 공격입니다. 여기서는 c = 1이었습니다.

Underflow를 유도한 공격자의 트랜잭션https://etherscan.io/tx/0x233107922bed72a4ea7c75a83ecf58dae4b744384e2b3feacd28903a17b864e0#eventlog

그 이후 PoWHcoin의 최대 잔고를 보유한 공격자는 한차례의 시행착오를 거친 후 거의 최대치의 PoWHcoin을 컨트랙트에 sell하여 총 866ETH를 가져오게 됩니다.

866ETH를 탈취한 트랜잭션https://etherscan.io/tx/0x496c0411f52978dfd7953b7e6965465977162bfaf7b88c0c78fcdc97cd395d62

3. Solution

이러한 Solidity의 기본 아키텍처의 취약점을 이용한 공격의 가능성은 익히 알려져있으며, 따라서 대처법도 충분히 연구가 된 상태입니다. Solidity는 0.8버전의 컴파일러에서부터 overflow와 underflow를 자동으로 감지하여 error 메세지를 출력하도록 하였습니다. 따라서 현재는 이러한 공격은 Solidity의 최신 버전을 사용하여 컨트랙트를 deploy하기만 해도 피할 수 있습니다.

만약 구버전의 Solidity를 사용하더라도, 해법은 간단한 함수를 컨트랙트에 추가하는 것으로 충분합니다. 트랜잭션으로 인해Over/Underflow가 생기는지 여부를 체크하고, Over/Underflow가 발생한다면 Revert시키면 되는 것이지요.

단 매뉴얼하게 함수를 추가하지 않고도, Openzeppelin의 Safemath library를 import해서 사용하는 방법이 일반적으로 추천됩니다. 다음은 Safemath library에서 덧셈 연산을 수행하는 tryadd 함수입니다. 이를 살펴보면서 구체적인 해결 방법에 대해 알아봅시다.

library SafeMath {
/**
* @dev Returns the addition of two unsigned integers, with an overflow flag.
*
* _Available since v3.4._
*/
function tryAdd(uint256 a, uint256 b) internal pure returns (bool, uint256) {
unchecked {
uint256 c = a + b;
if (c < a) return (false, 0);
return (true, c);
//해당 함수의 overflow를 막을 수 있는 제약조건은, c < a가 아닐 때만 덧셈 연산이 true
}
}

SafeMath 라이브러리에서 c = a +b 라는 정수합의 결과를 출력하는 것이 tryadd함수입니다. a + b 의 연산 결과가 uint256로 정의된 변수가 가질 수 있는 최댓값인 2^256-1보다 커지는 경우 arrithmetic overflow가 발생해 의도한 결과값인 c 와 동일하지 않게 됩니다. 이 경우 위의 사례에서 살펴본 바와 같이 취약점으로 악용될 소지가 있으므로, tryaddrevert를 출력하여 연산을 수행하지 않습니다.

다만 왜 overflow를 확인하는 if 조건문이 c < a 만을 만족하면 되는지에 대해서 의문이 들 수 있습니다. 먼저, overflow를 어떻게 식으로 구현할 지 날것의 아이디어를 생각해보면 다음과 같습니다.

c = a + b — 2²⁵⁶

그러나 이더리움의 스마트컨트랙트는 gas efficiency가 가장 중요한 덕목 중 하나입니다. 따라서 변수와 연산의 종류와 양을 최소화하는 것이 좋습니다. 따라서 아래의 산식을 활용해 Overflow의 동치조건을 간소화된 식인 c < a로 변형하여 표현하게 된 것입니다. 아래는 그 증명을 설명해보겠습니다.

  1. Overflow가 나는 것을 c = a + b — 2²⁵⁶ 와 동치로 생각할 수 있음.
  2. buint256이므로 b ≤ 2²⁵⁶ - 1

1, 2에서, c = a + b — 2²⁵⁶ ≤ a + (2²⁵⁶ — 1) — 2²⁵⁶ = a — 1

따라서, c ≤ a — 1

c 는 정수이므로, 위의 식은 c < a 와 동치.

4. 소결

위에서 살펴본 것처럼, Arithmetic Overflow, Underflow는 솔리디티 아키텍처의 한계를 이용한 공격 방식입니다. 그러나 이미 과거 다양한 언어에서 다뤄진 바 있는 문제이며, 개발자들도 주지하고 있는 방식이었기에 현재는 0.8버전부터 Solidity의 컴파일러 단에서 에러를 표현해주고 있고, 그 이전에도 Openzeppelin의 Safemath 라이브러리를 import하여 간단하게 해결할 수 있는 방식이었습니다.

5. Conclusion : Contract에 “알고 사인하는” 그날까지

Introduction에서 언급했듯 스마트 컨트랙트 취약점으로 인한 해킹 피해 규모는 디파이의 발전에 따라 날로 커져가며 2022년에는 무려 $2.7B에 이르렀습니다. 이는 2020년에 비해 1250%나 증가한 수치입니다.

우리는 앞에서 이러한 해킹에 사용된 다양한 기법들, 이를테면 Unsafe Delegatecall, Front Running, Signature Replay, Re-Entrancy Attack 등에 대해 알아보았습니다.

물론 이러한 기법을 알아낸다고 하더라도 개인 수준에서 스마트 컨트랙트 보안에 대해 알아보는 것에는 명확한 한계가 존재합니다. 이를 개선하기 위해 대부분의 스마트 컨트랙트는 오픈소스 형태로 공개되어 커뮤니티에게 보안에 대한 피드백을 받고, Bug Bounty Program을 운영하여 보안적 취약점을 찾아내는 것에 대한 보상을 제공하기도 합니다. 더불어 Openzeppelin, 해치랩스, Certik 등 전문적인 보안감사업체로부터 Audit 서비스를 받으며 취약점에 대한 전문적인 검토를 거치는 노력을 기울이기도 합니다.

요즘은 사용자가 개인 지갑을 통해 스마트 컨트랙트와 상호작용할 때의 번거로움을 개선하기 위한 솔루션들도 존재합니다. Anchian.AI와 같은 프로젝트는 블록체인 상에 deploy된 스마트 컨트랙트를 추적하여, AI를 활용해 위험도가 높은 스마트 컨트랙트나 악의적으로 판단되는 행위를 한 기록이 있는 컨트랙트와의 상호작용에 대해 경고를 해줍니다. ChainGuardian과 같은 솔루션도 비슷한 서비스를 제공하며. 이외에도 정말 다양한 솔루션들이 존재하고 또 등장하고 있습니다.

https://github.com/MetaMask/metamask-snaps-beta

그렇다면 이러한 서비스들이 잘 쓰이지 않은 이유는 무엇일까요? 첫째, 가장 큰 이유는 바로 대부분의 사람들은 이 서비스들을 잘 모르기 때문입니다. 대부분의 B2C Web3 보안 솔루션은 마케팅의 한계로 인해 대부분의 유저들이 존재 자체를 거의 알지 못합니다. 둘째로는 근본적으로 편리성을 보안보다 중요시하는 사람들의 인식에 기인합니다. 그리고 이러한 경향성을 바꾸기는 상당히 요원해보입니다. 대신, 사람들이 이미 광범위하게 채택한 주요한 지갑 솔루션에서 자체로, 혹은 서드파티 익스텐션으로 이러한 스마트 컨트랙트 보안 경고 툴을 제공하여 한층 더 보안의 수준을 끌어올릴 수 있을 것으로 기대해 볼 수 있을 것입니다.

최근 메타마스크는 Metamask Snap이라는 서비스를 런칭하였습니다. 이는 메타마스크를 이용해 다양한 기능을 덧붙이거나 추가하는 익스텐션을 제공할 수 있도록 만듭니다. 메타마스크의 시장 점유율을 감안할 때, Anchian.AI와 유사한 솔루션들이 Metamask를 통해 내장되어 취약점이 존재하는 스마트 컨트랙트와 상호작용할 때 alert를 띄워줄 수 있다면 Code Literacy만으로 달성할 수 없는 수준의 보안까지 일반 유저들도 달성할 수 있을 것입니다.

https://blockgeeks.com/guides/smart-contracts/

그러나 근본적인 수준에서의 보안은 바로 실제 사용자들의 Code Literacy와 보안의식에서 파생됩니다. 누군가에게 돈을 빌려줘 보신 적 있으신가요? 우리가 온체인이 아닌, 현실에서 누군가에게 큰 돈을 빌려줄 때는 차용증이라는 계약서를 작성할 것이며, 상대가 계약서를 들고 왔더라도 순순히 사인을 하는 것이 아니라 계약의 내용에 대해 꼼꼼하게 살펴볼 것이 자명합니다. 취직을 해서 일을 한다면요? 회사에서 일을 하기 위해 근로계약서를 작성하는 경우에도, 당연히 꼼꼼하게 세부 조건을 파악해서 불리한 조항이나 불이익을 볼 여지가 없는지 검토할 것입니다.

이런 현실의 법률로 정의된 계약서는 블록체인에서는 스마트 컨트랙트와 동치입니다. Code is Law라는 말은 블록체인 상에서는 정확한 표현입니다. 계약에 참여하기 위해 내 소중한 잔고에 대한 권한을 Approve하거나, 내 잔고에 간섭하는 행위가 가능하도록 Sign하기 전에 계약의 내용을 직접 검토해보는 일은 어쩌면 당연한 일일 수 있습니다. 만약 당연하진 않더라도, 분명 아주 의미있는 일일 겁니다. 비단 전문가와 같은 수준의 이해를 갖추는 것이 불가능할지라도 말입니다.

--

--