실타래 NFT Root Cause Analysis

좀 지난 이야기이긴 하지만, 2월 26일에 Klaytn 기반의 NFT인 실타래에서 데이터 Reveal 과정에서 해킹으로 인하여 희귀 카드인 이순신 카드가 탈취 되었다.

게임 관련 NFT들은 확률적인 부분을 넣기 위해서 난수를 사용하고는 한다. 컴퓨터가 정말 아무 것도 없이 임의의 수를 고르는 것은 불가능하기 때문에, (모든 소프트웨어는 입력이 주어져야 하기 때문에, 난수 프로그램도 예외는 없다.) 일반적인 소프트웨어 실행 환경에서 난수는 사용자의 마우스 움직임이라던가, CPU 온도라던가 하는 예측 하기 어려운 값들로부터 난수 값을 생성한다.

스마트 컨트랙트 실행 환경은 기본적으로 스마트 컨트랙트의 실행값을 네트워크 참여자들이 모두 상호 검증하는 방식으로 이루어 지기 때문에, 스마트 컨트랙트에서 사용할 수 있는 값은 모두가 계산 가능한 값 이어야 만 한다. 따라서 스마트 컨트랙트에 존재하는 값만으로 만들어진 난수는 모두 예측이 가능한 것이다.

이번 실타래의 경우도, Reveal에 사용하는 난수 생성 함수를 오직 스마트 컨트랙트에 존재하는 값 만으로 구현하였기 때문에 문제가 발생한 것이다.

var var3 = keccak256(block.timestamp);
var temp2 = keccak256(block.timestamp) % var2;
Decompile 된 SYLTARE Reveal Contract의 일부

Reveal에 포함된 NFT ID 결정 코드의 일부이다. 블록 생성 시간을 keccak256이라는 해시 알고리즘으로 해시한 값을 특정 값으로 나눈 나머지를 가지고 NFT ID를 결정한다. 여기서 자세하게 결정식을 다루지는 않겠지만, 아주 간단한 연산으로 예측이 가능하다.

그럼 여기서 의문이 하나 생긴다. 블록 생성시간인 block.timestamp 는 블록 생성 시에 결정되는데, 이걸 어떻게 예측해서 난수를 맞춘단 말인가? 심지어 그냥 쓰는 것도 아니고 해시 알고리즘으로 연산한 결과값을 사용 않는가? 해시 알고리즘으로 연산하면 그 값이 1만 차이나도 엄청나게 결과값에 차이가 나는데, 어떻게 저 값을 정확히 예측해서 결과값이 이순신의 NFT ID인 1111이 나오게 할 수 있는가?

답은 간단하다. 스마트 컨트랙트를 만들어서 내가 예측한 블록 생성 시간과 맞지 않으면 실행을 멈추면 그만이다. Klaytn의 블록 생성 주기가 1~2초 사이인것은 누구나 알고 있는 사실이니, 내가 예측한 블록 생성 시간이 실제 블록 생성 시간과 같으면 reveal을 진행하고, 아니면 트랜젝션을 철회하는 컨트랙트를 만들어 실행시키면, 컨트랙트가 실행되는 시점에는 이미 블록 생성 시간이 결정되었으니 충분히 확인 할 수 있는 것이다.

실제로, 공격자인 0x435929e065fc6abc36c93ed0096ceb3c016ae09f는 공격용 스마트 컨트랙트를 만들어서 예측값이 정확히 맞는지 검증하는 과정을 거쳤다.

그럼, 막을 수 없는 사고였나?

블록체인에서 Bad Randomness 취약점은 아주 유구한 역사를 가진 취약점이다. 많은 보안 전문가들이 난수 생성 시에 block.timestamp, block.number 등의 값을 사용하지 않도록 권장하고 있다. 아마도 실타래가 보안 감사를 받았더라면, 분명 이에 대해서 고지 받았을 것이다.

나는 이 사건은 개발자의 부주의 혹은 무지에 의해 일어난 인재(人災)라고 본다. 많은 DeFi / NFT들이 난수 생성을 위해 Chainlink VRF와 같은 암호학적으로 어느정도 안전하다고 검증된 난수 생성기를 비용을 지불하며 사용한다. 물론, Klaytn에서는 아직 Chainlink VRF를 사용할 수 없지만, VRF를 사용할 수 없다면 별도의 블록체인 외부 데이터를 사용해서 Random을 생성하게 했어야 한다.

모범 사례로 Wanchain을 사용하는 Zookeeper.finance의 사례를 보자. Wanchain도 Ethereum 기반의 사이드체인으로 Chainlink VRF를 지원하지 않는다.

    function randomNFT(address user, bool golden) private view returns (uint tokenId, uint level, uint category, uint item, uint random) {
        uint totalSupply = IZooNFTMint(zooNFT).totalSupply();
        tokenId = totalSupply + 1;
        uint random1 = uint(keccak256(abi.encode(tokenId, user, blockhash(block.number - 30), getRandomSeed())));
        uint random2 = uint(keccak256(abi.encode(random1)));
        uint random3 = uint(keccak256(abi.encode(random2)));
        uint random4 = uint(keccak256(abi.encode(random3)));
        uint random5 = uint(keccak256(abi.encode(random4)));
ZooKeeper.finance의 Random Code

이곳도 얼핏 보기에는 block.number라는 예측가능한 값 만으로 난수를 생성하는 것 같아 보인다. 하지만 그들은 한가지 값을 더 사용하는데, randomSeed라는 것을 난수 생성에 사용한다.

ZooKeeper.finance는 mint를 호출 했을 때, 바로 random으로 NFT의 값들을 결정하지 않고, mint 요청들을 저장해 두었다가, Oracle이라고 불리는 외부 데이터 소스가 임의의 숫자인 randomSeed를 제공하면 그 값에 기반하여 난수를 생성하여 NFT의 값들을 채워넣는 식으로 난수를 예측할 수 없도록 해두었다.

Klaytn은 가스비가 그리 비싸지 않기 때문에, 매 요청에 임의의 난수를 생성해 보내는 Oracle 봇을 만들어 돌리는 것이 그렇게 비용적으로 부담이 되지는 않을 것이다.

이런 측면에서 볼 때, 해킹 불가능한 랜덤을 만들 수 없다는 이두희 님의 말은 언어도단이다. 온체인 데이터 만으로 해킹 가능한 랜덤을 만드는 것이 불가능 하다는 것을 알고 있었다면, 이러한 시스템을 구현하지 말았어야 하며, 그 사실을 몰랐다면, 보안 위협에 대해서 감사를 맡겼어야 한다.

결론

지금까지 실타래 NFT Reveal 해킹 사고의 원인과 해결 방법에 대하여 간단하게 다루어 보았다. 실타래 NFT Reveal은 누구나 쉽게 예측 할 수 있는 값을 난수로 NFT ID를 생성하고 있었고, 악의적인 누군가가 그를 악용하여 희귀 NFT를 뽑은 사건이다.

그 사고 경위나 대응, 후속 조치에 있어 실타래 프로젝트는 다소 아쉬운 모습을 보여주고 있다. 조만간 다시 실타래의 Reveal이 있을 예정이라고 한다. 그 때에는 보안감사를 받거나, 난수 예측 공격에 대한 안전한 대응책을 가지고 진행하기를 기대해 본다.

* 본 포스팅의 내용은 어떠한 투자상품의 안정성도 보장하지 않으며, 이 글을 참조하여 이루어진 투자와 그로 인해 발생하는 그 어떠한 피해에 대해서도 책임지지 않습니다.