How I Stole Your Siacoin

Michael Lynch

내가 당신의 Siacoin을 훔친 방법

수상한 Reddit 게시물

때는 2017년 6월 9일 밤이었습니다. 여느 금요일 밤과 다를 것 없는 밤이었죠. 저는 넷플릭스를 보며 Reddit을 확인하고 있었습니다 잘나가는 친구들과 파티를 즐기고 있었습니다.

그때 갑자기 /r/siacoin 서브레딧의 ‘New’ 탭에서 이런 게시물을 보게 됐습니다.

Reddit 스크린샷

Siacoin이 익숙하지 않다면, Siacoin은 남는 하드 디스크 공간을 다른 사람에게 빌려주거나 다른 사람의 공간을 구매할 수 있게 해주는 암호화폐입니다. 저는 예전에 이 기술에 관해 몇 차례 글을 쓴 적이 있습니다(채굴 가이드, NAS 가이드).

이 Reddit 사용자는 방금 아주 위험한 일을 저질렀습니다. 자신의 Sia 지갑 시드(seed)를 온라인에 올린 것입니다. Sia의 ‘시드’는 이를 가진 사람에게 해당 지갑에 들어 있는 암호화폐를 완전히 통제할 수 있는 권한을 주는 비밀번호 문구입니다. 이 사용자에게는 2,000유로어치의 Siacoin이 걸려 있었습니다. 그만큼의 금액을 그 지갑으로 옮겨 두었기 때문입니다. 스크린샷을 보면 사용자는 비밀번호 문구가 작동하지 않는다는 것을 확인했으니 안전하다고 생각하고 있었습니다.

거의 맞았다는 건 소용없다(편자와 Sia 시드는 예외)

이 게시물에서 제 관심을 끈 것은 사용자가 시드를 손으로 직접 적었다는 점이었습니다.

적어 놓을 때 실수하지 않았다고 거의 확신해요. 항상 두 번씩 확인하거든요.

저는 사용자가 실제로는 적으면서 실수했다고 확신했습니다. 다만 실수가 한 번뿐이기를 바랐습니다. 글자 하나를 잘못 적었거나 두 글자의 순서를 바꾼 정도라면 올바른 시드를 알아내 2,000유로를 되찾을 수 있을 것 같았습니다.

빨리 처리해야 했습니다. 유출된 시드를 알아보는 사람이 저뿐인 건 아니었으니까요. 다른 사람이 먼저 시드를 해독해 돈을 가져가기 전에 얼른 시드를 풀고 돈을 챙겨야 했습니다.

손으로 하는 해킹

우선 잘못 적힌 시드의 단어들을 살펴봤습니다.

eluded logic wise ascend tagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public tonic vulture shrugged otter adapt

저는 Sia가 시드를 어떻게 생성하는지 몰랐지만, Sia는 완전히 오픈 소스였으므로 알아내기가 그리 어렵지는 않을 것 같았습니다.

실제로도 어렵지 않았습니다. Sia의 wallet.go 파일을 살펴보다가 SeedToString 함수를 발견했습니다. 그 함수는 가능한 시드 단어들이 담긴 entropy-mnemonics GitHub 프로젝트로 이어졌고, 그 안에는 다음과 같은 단어 목록이 있었습니다.

  englishDictionary = Dictionary{
    "abbey",
    "abducts",
    "ability",
    "ablaze",
    "abnormal",
    "abort",
    "abrasive",
    "absorb",
    "abyss",
    "academy",
    "aces",
    "aching",
    "acidic",
    "acoustic",
    "acquire",
    "across",
    "actress",
    ...

엔트로피 사전에는 약 1,600개의 단어만 들어 있었습니다. 사용자가 시드를 받아 적다가 사전에 아예 없는 단어를 실수로 적었기를 바랐습니다. 그러면 게시한 시드 단어 29개 중 사전에 없는 단어 하나를 찾아내기만 해도 그 단어가 틀린 단어라는 사실이 분명해집니다. 그다음 사전에서 빠진 단어와 비슷한 단어를 찾기만 하면 시드를 금방 알아낼 수 있을 테니까요.

하지만 안타깝게도 잘못된 시드에 있는 29개 단어가 모두 엔트로피 사전에 들어 있었습니다. 눈대중으로는 해결할 수 없는 일이었습니다.

무차별 대입

이제 비장의 무기를 꺼낼 때였습니다(제가 코드를 입력할 때 쓰는 손가락 두 개를 ‘무기’라고 부릅니다). Reddit에 올라온 시드의 각 단어와 복사 과정에서 한 번 실수했을 때 나올 수 있는 사전 속 단어를 전부 찾아낼 방법이 필요했습니다.

여기서 레벤시테인 거리가 도움이 될 수 있겠다는 생각이 들었습니다. 레벤시테인 거리란 한 단어를 다른 단어로 바꾸기 위해 글자를 추가하거나 삭제하거나 교체해야 하는 횟수입니다. 예를 들어 ‘cat’과 ‘car’의 레벤시테인 거리는 1입니다. ‘cat’의 ‘t’를 ‘r’로 바꾸면 ‘car’가 되기 때문입니다. ‘cat’과 ‘scar’의 거리는 2입니다. ‘t’를 바꾸고 앞에 ‘s’를 붙여야 하니까요.

가능한 시드를 찾기 위해, 잘못된 시드의 각 단어와 레벤시테인 거리가 1인 단어를 엔트로피 사전에서 찾아내는 스크립트를 작성하면 됐습니다.

먼저 사전을 로컬에 내려받은 뒤 a부터 z까지의 문자만 남겼습니다.

$ wget -qO- https://raw.githubusercontent.com/NebulousLabs/entropy-mnemonics/master/english.go \
  | egrep "^\s+"(.+)"," \
  | egrep -o [a-z]+ \
  > dictionary.txt

그런 다음 python-Levenshtein 라이브러리를 설치하고 가능한 시드를 출력하는 조잡한 Python 스크립트를 작성했습니다.

import Levenshtein

seed = raw_input('enter your wallet seed: ')

for seed_word in seed.split():
  for dict_word in open('dictionary.txt'):
    dict_word = dict_word.strip()
    distance = Levenshtein.distance(seed_word, dict_word)
    if distance != 1:
      continue
    print '"%s" -> "%s"\n%s\n' % (seed_word, dict_word,
                                  seed.replace(seed_word, dict_word))

고백하자면 실제 스크립트는 훨씬 더 엉성했고, 사전의 1,600줄을 그대로 복사해 Python 스크립트에 붙여 넣었습니다. 위 코드는 설명을 위해 더 보기 좋게 만든 것입니다.

금고 열기

가능한 경우의 수가 수백 개나 되어서 각각의 시드를 시도하는 과정까지 스크립트로 짜야 할까 봐 걱정했습니다. 다행히 제 스크립트가 알려준 경우의 수는 잘못된 시드와 레벤시테인 거리가 1인 시드 12개뿐이었습니다.

$ python recover.py
enter your wallet seed: eluded logic wise ascend tagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public tonic vulture shrugged otter adapt

"wise" -> "wife"
eluded logic wife ascend tagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public tonic vulture shrugged otter adapt

"tagged" -> "jagged"
eluded logic wise ascend jagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public tonic vulture shrugged otter adapt

"tagged" -> "nagged"
eluded logic wise ascend nagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public tonic vulture shrugged otter adapt

"aptitude" -> "altitude"
eluded logic wise ascend tagged acoustic situated stylishly younger altitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public tonic vulture shrugged otter adapt

"push" -> "lush"
eluded logic wise ascend tagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar lush because brunt viking gone august public tonic vulture shrugged otter adapt

"brunt" -> "grunt"
eluded logic wise ascend tagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because grunt viking gone august public tonic vulture shrugged otter adapt

"tonic" -> "ionic"
eluded logic wise ascend tagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public ionic vulture shrugged otter adapt

"tonic" -> "sonic"
eluded logic wise ascend tagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public sonic vulture shrugged otter adapt

"tonic" -> "topic"
eluded logic wise ascend tagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public topic vulture shrugged otter adapt

"tonic" -> "toxic"
eluded logic wise ascend tagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public toxic vulture shrugged otter adapt

"adapt" -> "adept"
eluded logic wise ascend tagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public tonic vulture shrugged otter adept

"adapt" -> "adopt"
eluded logic wise ascend tagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public tonic vulture shrugged otter adopt

가능성이 충분히 적었기 때문에 Sia에 직접 하나씩 입력해도 됐습니다. 먼저 잘못된 시드의 wisewife로 바꾼 첫 번째 시드를 입력해 봤습니다.

> siac wallet init-seed
Seed:
Could not initialize wallet from seed: error when calling /wallet/init/seed: seed failed checksum verification

예상했던 결과였습니다. 모든 조합이 유효한 시드일 리는 없으니까요. 저는 tonicionic으로 바꾼 시드에 도달할 때까지 가능한 시드를 계속 입력했습니다.

> siac wallet init-seed
Seed:
Wallet initialized and encrypted with seed.

대박이었습니다!

안에 뭐가 있는지 확인해 봅시다.

> siac wallet unlock
Wallet password:
Wallet unlocked

> siac wallet
Wallet status:
Encrypted, Unlocked
Confirmed Balance:   594.8 SC

이상했습니다. 당시 594.8 SC(Siacoin)는 약 10유로에 해당했는데, 사용자가 지갑에 있다고 주장한 2,000유로와는 거리가 한참 멀었습니다.

혹시 어떻게든 제가 당한 걸까요? 사용자는 실제로는 10유로밖에 없다는 걸 알면서도, 누군가 도와주도록 유인하려고 훨씬 많은 금액이 있다고 거짓말한 걸까요? 아니면 더 뛰어난 암호화폐 도둑이 먼저 지갑에 들어와 저를 놀리려고 10유로만 남겨 둔 걸까요?

전리품 확보

눈앞에 보이는 이상한 잔액을 앉아서 곰곰이 생각해 보고 싶었지만, 시간이 중요했습니다. 그 게시물을 본 다른 사람이 누구인지, 저처럼 지갑을 곧 잠금 해제하려는 사람이 있는지 알 수 없었습니다. Siacoin을 훔칠 때가 된 것입니다.

벤 게이츠: 다른 누군가도 보물을 노리고 있어.

라일리 풀: 당연히 다른 사람도 보물을 노리지. 보물찾기의 공리 같은 거야.

-내셔널 트레저: 비밀의 책

저는 곧바로 잔액 전부를 제 Sia 지갑으로 보냈습니다. 그러면 제가 올바른 시드를 찾아낸 뒤 다른 사람이 그 시드를 알아내더라도 돈을 되찾아 갈 수 없을 테니까요.

다시 미스터리로

코인을 확보했으니, 이제 대체 무슨 일이 벌어진 것인지 알아볼 차례였습니다. 지갑의 거래 내역을 확인했습니다.

> siac wallet transactions
    [height]                                                   [transaction id]    [net siacoins]   [net siafunds]
      108589   427b72c98e8ea64fba234ca2a00288f7a750003a243e6b3e967f5c6d426c2f9f         594.83 SC             0 SF
      109002   32ad2729fe6b487aedc1b70d0dff0843404ff1cef69223d5f03699dcd1dbe568           0.00 SC             0 SF
      109002   2304da26d61bd2cb7fcac5c7b38a553d788d8dfc386ae4eb47772e36e4a9269d        -594.55 SC             0 SF
하디 보이즈 표지

목록의 마지막 거래가 출금입니다. 제가 돈을 훔친 거래죠. 이건 걱정하지 않아도 됩니다. 0.00 SC 거래는 그저 잡음일 뿐입니다. Sia 지갑은 자체 주소 간에 돈을 옮길 때 0.0 거래를 생성하기 때문입니다.

제가 관심을 가진 것은 목록의 첫 번째 거래였습니다. 이 줄을 보면 이 지갑은 블록 높이 108,589에서 594.83 SC를 입금받은 것이 전부였습니다. 블록 높이는 본질적으로 Siacoin의 시간 단위로 표시한 ‘시간’입니다. Sia 블록 탐색기에서 거래 내역을 확인해 보니, 이 입금은 사용자가 Reddit에 게시물을 올리기 이틀 전인 2017년 6월 7일에 이루어졌습니다.

사용자는 고작 10유로만 입금해 놓고 왜 지갑에 2,000유로를 넣었다고 주장한 걸까요?

어딘가에 묶인 거래

제가 대담한 강탈을 벌이던 당시, Siacoin 최대 거래소인 Poloniex는 Siacoin을 사용자의 지갑으로 전송하는 데 문제를 겪고 있었습니다. 사용자 자금을 잃어버린 것은 아니었지만, 거래가 ‘림보’ 상태에 빠지는 일이 흔했습니다. 사용자가 거래소 계정에서 개인 Sia 지갑으로 돈을 보냈는데 Poloniex의 처리 지연으로 며칠 또는 몇 주 동안 전달되지 않는 상황이었습니다.

어쩌면 이 사용자는 지갑으로 2,000유로를 보냈지만, 돈이 Poloniex의 림보에 갇혀 있었을 수도 있습니다. 그렇다면 그 2,000유로는 결국 지갑에 도착할 테니 여전히 누군가 차지할 수 있는 돈일지도 몰랐습니다.

새롭고 흥미로운 문제가 생겼습니다. 아직 지갑에 도착하지도 않았고 언제 도착할지도 모르는 돈을 어떻게 훔칠 수 있을까요? 저는 노출된 지갑에서 제 지갑으로 돈을 계속 옮기는 배치 스크립트를 작성하기로 했습니다. 아니, 정확히는 배치 스크립트를 어떻게 작성하는지 배우기로 했습니다. 제가 당장 사용할 수 있는 가장 쉬운 Sia 환경이 Windows 가상 머신이었는데, Windows에서 배치 스크립트를 작성할 줄 몰랐기 때문입니다. 결국 저는 다음과 같은 훌륭한 배치 스크립트를 완성했습니다.

for /l %%x in (1, 0, 100) do (
   siac wallet send siacoins 2000SC fff0228f02a01cf8e037047a5ea0db5a88d614913af5f21de209ebf2e58431c68cfc9c27d0e4
)

이 스크립트는 Reddit 사용자의 침해된 지갑에서 제 지갑 주소로 2,000 SC를 보내는 작업을 반복해서 시도합니다. 1부터 100까지 0씩 증가하므로 무한히 반복됩니다.

지갑 잔액이 계속 0인 동안에는 이 명령이 아무런 효과 없이 실패합니다. 하지만 제가 기대한 2,000유로가 지갑에 입금되면, 한 번에 2,000 SC씩 제 지갑으로 빼돌리게 됩니다.

저는 2,000 SC를 선택했습니다. 비교적 적은 금액을 보내는 편이 더 안전했기 때문입니다. 사실상 더 프라이스 이즈 라이트의 규칙에 따라 움직인 셈입니다. 예를 들어 당시 2,000유로에 해당하는 125,000 SC를 지정했는데 지갑에 124,000 SC만 들어오면, 명령은 잔액 부족 오류로 실패하고 아무것도 전송하지 못했을 것입니다.

너무 적은 금액을 추측해도 실질적인 불이익은 없었습니다. 거래 수수료를 더 많이 내야 한다는 점을 빼면요. 2,000 SC는 약 35유로였으므로, 약 125,000 SC(2,000유로)가 입금되었다면 제 배치 스크립트는 몇 분 안에 지갑을 비웠을 것입니다.

피해자에게 알리기

그 10유로를 제가 계속 갖고 있다면 무엇을 살 수 있을지 꽤 많은 망상을 해 본 것은 인정합니다. 전용 제트기, 롤렉스 시계, 스크루지 맥덕처럼 돈으로 가득 찬 수영장이 있는 저택 같은 것들 말이죠. 하지만 결국 옳은 일을 해야 한다고 생각했고, 시드를 게시한 사용자에게 Siacoin을 돌려주기로 했습니다.

아메리칸 사이코

내가 돈을 계속 갖고 있었다면 이런 모습이었을 겁니다.

그래도 제가 발견한 금액과 사용자가 잃어버렸다고 생각한 금액의 차이는 꽤 난처한 상황을 만들 수 있었습니다. 이런 식으로 전화를 거는 것과 비슷하겠죠. “저기, 현금 2,000유로가 들어 있는 잃어버린 지갑을 찾는다는 전단을 붙인 분 맞죠? 제가 찾았는데, 안에는 어… 10유로밖에 없네요…”(시선을 피함).

사용자가 처음 게시물을 올린 지 약 두 시간 뒤, 저는 Reddit으로 비공개 메시지를 보냈습니다. 제가 어떻게 시드를 복구했고, 다른 악의적인 사용자가 시드를 복구해 돈을 가져가지 못하도록 돈을 안전하게 옮겨 두었다고 설명했습니다. 그리고 유출된 시드와 연결되지 않은 Siacoin 주소를 알려 달라고 요청했습니다. 그러면 Siacoin 잔액을 돌려줄 수 있으니까요.

몇 시간이 지나고, 며칠이 지나도 답장은 오지 않았습니다. 사용자가 시드를 노출한 Reddit 게시물을 삭제했다는 사실도 알아챘습니다. 2,000유로를 잃어버려 온라인에 도움을 요청하는 글까지 올린 사람이 몇 시간 뒤에 그 일을 완전히 잊어버린 듯 행동한다니, 대체 어떤 사람일까요?

미스터리 해결

마침내 월요일 아침, 저의 흉악한 범죄 피해자가 연락해 왔습니다. 사용자는 게시물을 올린 직후 돈이 아직 거래소에 남아 있고 지갑에는 들어오지 않았다는 사실을 깨달았다고 설명했습니다(역시 내 예상이 맞았습니다!). 사용자는 시드가 안전한 별도의 지갑으로 돈을 옮길 수 있었습니다. 실제로 돈을 잃은 것이 아니라는 사실을 알게 되자 Reddit에 다시 들어가 확인해야 한다는 생각을 하지 못했던 것입니다.

사용자는 제가 시드를 복구한 것을 무척 기뻐했습니다. 비밀번호 문구에 무슨 문제가 있었는지에 대한 자신의 미스터리를 제가 해결해 주었기 때문입니다. 사용자는 ionic을 정확히 적었지만, 자신에게 더 익숙한 단어가 tonic이었기 때문에 계속 잘못 읽고 있었습니다. 사용자는 심지어 전액을 제가 가져도 좋다고 했지만, 저는 이 블로그 글에서 제가 더 괜찮은 사람처럼 보이려면 그 코인은 정당한 주인인 잃어버린 사용자에게 돌아가야 한다고 생각했습니다. 저는 끝까지 고집했고, 결국 사용자는 594.8 SC를 돌려줄 수 있도록 주소를 보내 주었습니다.

교훈

Sia 지갑 시드를 절대 온라인에 올리지 마세요. 이 이야기에서 보듯 시드가 틀렸거나 일부만 노출된 경우에도 지갑이 완전히 위험해질 수 있습니다.

이는 Siacoin뿐 아니라 암호화폐 전반에 해당합니다. Sia처럼 비밀번호 문구를 사용하는 암호화폐만 있는 것은 아니지만, 코인을 잃고 싶지 않다면 반드시 비밀로 지켜야 하는 어떤 형태의 개인 키든 모두 사용합니다.

원문은 Michael Lynch님이 에 게재했습니다.

이 글은 gpt-5.6-luna 모델을 사용해 번역했습니다.