synology / self-hosting / security / performance

랜섬웨어

eCh0raix 복호화 키를 찾으려고 Go 난수 시드 21억 개를 전부 돌린 기록

Go math/rand의 시드가 2³¹-1 공간으로 줄어드는 점을 이용해 eCh0raix 키 후보를 4가지 방식 × 6가지 문자 집합으로 86분 동안 전수 탐색한 방법과 결과입니다.

시놀로지 NAS가 eCh0raix 랜섬웨어에 걸려 사진 23,015개가 암호화됐습니다. 감염 시각은 2021년 9월이라 "키를 공격자 서버에서 받아 오는 후기 변종이라 복구 불가"라는 게 일반적인 결론이었습니다. (변종을 가린 과정은 앞 글에 있습니다.)

그래도 사진이라 쉽게 포기가 안 됐습니다. 초기 변종처럼 시간을 시드로 키를 만들었을 가능성을 끝까지 확인해 보기로 했고, Go의 난수 생성기가 받을 수 있는 시드 약 21억 개를 전부 돌렸습니다. 86분 걸렸고, 키는 없었습니다. 이 글은 그 과정과 중간에 틀렸던 부분을 정리한 겁니다.

키를 맞혔는지 어떻게 빨리 아나

키 후보를 만들 때마다 파일 전체를 복호해 보면 너무 느립니다. 대신 파일 형식의 첫 바이트를 이용했습니다.

JPEG는 항상 FF D8 FF로 시작하고 PNG는 89 50 4E 47로 시작합니다. CFB 모드에서 첫 16바이트 평문은 AES(키, IV) XOR 첫 암호문 블록입니다. 그러니 키 후보 하나당 AES 블록 암호화 한 번만 하면, 첫 몇 바이트가 JPEG 시그니처로 나오는지 바로 알 수 있습니다.

우연히 맞는 경우를 없애려고 샘플을 5개(JPEG 3개, PNG 2개) 뽑아 전부 맞아야 통과하게 했습니다.

func testKey(key []byte) bool {
    block, err := aes.NewCipher(key)
    if err != nil {
        return false
    }
    enc := make([]byte, 16)
    for _, sm := range samples {          // 샘플 5개: IV, 첫 암호문 블록, 기대 시그니처
        block.Encrypt(enc, sm.iv)
        for i := range sm.magic {
            if sm.c0[i]^enc[i] != sm.magic[i] {
                return false
            }
        }
    }
    return true
}

처음 시도들이 틀렸던 이유

사실 이 탐색은 두 번째입니다. 처음에는 2021년 한 해의 초 단위 시각(약 3,150만 개)과 감염 시각 앞뒤의 나노초 시드를 돌렸고, 다 실패했습니다. 나중에 다시 보니 방법에 구멍이 두 개 있었습니다.

하나. 키를 만드는 코드를 r.Intn(n) 한 가지로만 가정했습니다. Go로 무작위 문자열을 만드는 흔한 코드는 여러 가지이고, 방식마다 난수를 꺼내 쓰는 양이 달라서 같은 시드에서도 전혀 다른 키가 나옵니다. 방식이 틀리면 맞는 시드를 지나가도 못 알아봅니다.

둘. "모델이 맞는지" 검증한다고, 복호기 저장소의 테스트 키 4STDs9cmUlkiujXuLkdTouoqOIfER4TE를 시드로 재현해 보려 했습니다. 재현이 안 되니 "시간 시드 방식이 아니다"라고 판단했는데, 알고 보니 이 키는 난수로 만든 게 아니라 Python AES 예제에 하드코딩된 상수였습니다. 처음부터 재현될 수 없는 값으로 검증한 거라 그 판단은 근거가 없었습니다.

시드는 사실 21억 개뿐입니다

다시 짜면서 Go의 math/rand 소스를 봤습니다. rand.NewSource(seed)는 받은 시드를 내부에서 2³¹-1로 나눈 나머지로 바꿔서 씁니다. 초 단위든 나노초 단위든, 어떤 int64 시드를 넣어도 결국 0 ~ 2,147,483,646 사이 값 하나가 됩니다.

그러니 시간을 추측할 필요가 없습니다. 이 21억 개를 전부 돌리면 시간 기반 시드는 100% 다 확인한 겁니다.

4가지 방식 × 6가지 문자 집합

키 생성 방식은 Go에서 흔히 쓰는 네 가지를 넣었습니다.

시험한 키 생성 방식
이름문자를 고르는 방법
Intnr.Intn(len(charset)) — 편향을 없애려고 범위를 넘는 값은 버리고 다시 뽑음
Int63%nr.Int63() % n
mask6Int63() 하나에서 6비트씩 잘라 여러 글자에 나눠 씀
Int31%nr.Int31() % n

문자 집합은 영문 대소문자 52자, 영문+숫자 62자의 순서 변형 4가지, 특수문자를 넣은 64자까지 6가지입니다. 합쳐서 시드 하나당 24개 키를 시험합니다.

시드마다 rand.New를 새로 만들면 느려서, 생성기 하나를 Seed(s)로 다시 맞추고 Int63() 48개를 미리 뽑아 둔 다음 네 방식이 그 배열을 나눠 쓰게 했습니다. 이렇게 바꾼 결과가 원래 방식과 같은지는 320만 건을 양쪽으로 돌려 비교해서 확인했습니다.

r := rand.New(rand.NewSource(1))
for s := a; s < b; s++ {
    r.Seed(s)
    for i := 0; i < 48; i++ {
        stream[i] = r.Int63()
    }
    for _, cs := range charsets {
        for m := 0; m < 4; m++ {
            if keyFromStream(m, stream, cs, key) && testKey(key) {
                // 찾음
            }
        }
    }
}

결과

16스레드로 나눠 돌렸고 86분 만에 21억 개를 다 봤습니다. 계산하면 초당 약 42만 시드, 키로는 초당 약 1천만 개입니다.

완료: 범위 내 키 없음.

이제 "시간 시드로 만든 키가 아니다"라고 확실하게 말할 수 있습니다. 보안 업체들이 보고한 대로 2019년 이후 변종은 키를 C2 서버에서 받는다는 설명과도 맞습니다.

키 말고 암호 구현의 약점으로 복구할 길도 따로 확인했습니다. 가장 큰 86MB 파일도 끝까지 전부 암호화돼 있었고, 22,888개 파일의 IV는 하나도 겹치지 않았습니다. 구현에 빈틈이 없다는 뜻입니다.

다른 피해자가 쓸 수 있는 것

제 결과가 "없음"이었다고 다른 사람도 없다는 뜻은 아닙니다. 2019년 이전에 감염됐다면 이 방식으로 키가 나올 가능성이 있습니다. 쓰려면 이렇게 하면 됩니다.

  1. 자기 파일에서 JPEG나 PNG 샘플 5개를 골라 앞 16바이트(IV)와 다음 16바이트(첫 암호문 블록)를 hex로 뽑습니다.
  2. 위 코드의 samples를 그 값으로 바꿉니다.
  3. 시드 범위 0 ~ 2³¹-1을 CPU 수만큼 나눠 돌립니다.

저는 키가 나오면 바로 쓸 수 있게 복호기를 준비해 두고, 암호화된 파일은 지우지 않고 보관 중입니다.

확인한 공식 자료

구현 과정에서 판단 기준을 교차 확인한 공식 문서입니다. 글의 사례와 결론은 운영자가 직접 겪은 작업을 바탕으로 작성했습니다.