2021.01.18


Reversing.Kr - Ransomware (Reversing, 120pts)

문제 제목이 요즘 매우 핫한(?) 악성코드 유형인 랜섬웨어(Ransomware)다. 문제 파일을 받으면 압축파일이 나오는데, 압축을 풀면 아래 [그림 1]과 같이 3개의 파일이 나온다.

[그림 1] Ransomware.zip


이 중에서 ReadMe.txt를 누르면 아래 [그림 2]와 같은 설명을 볼 수 있는데, ‘file’을 복호화하라는 심플한 조건과 제작자 이름만 있는 것을 볼 수 있다.

[그림 2] ReadMe.txt


정황상 ‘run.exe’가 이번에 분석해야 할 랜섬웨어인듯 하고, ‘file’은 그 랜섬웨어로 인해 암호화된 파일인 듯 하다. ‘run.exe’를 실행하기 전에, 우선 ‘file’의 파일 구조가 어떻게 되어있는지 보고자 HxD로 열어보았다.

[그림 3] HxD로 열어본 'file'


‘ReadMe.txt’에 따르면, ‘file’은 exe 파일임을 알 수 있다. 정상적인 exe 파일이라면 파일 시그니처로 ‘MZ’를 가져야하는데, ‘file’은 ‘MZ(0x4D, 0x5A)’가 아닌 0xDE, 0xC0로 시작하는 것을 볼 수 있다. HxD로 ‘file’ 전체를 쭉 훑어가면서 봤지만 그외 읽을 수 있는 문자열이나 의미를 알 수 있을만한 데이터는 보이지 않았다.

대충 볼 건 다 보았으니 이제 ‘run.exe’를 실행해보았다.

[그림 4] run.exe


위 [그림 4]와 같이 ‘run.exe’에서는 키를 입력받는 것을 볼 수 있다. 현재로서는 정확한 키를 알 수 없으니 아무거나 입력하면 아래 [그림 5]와 같은 화면을 볼 수 있다.

[그림 5] Is it Decrypted?


순간 파일이 복구된 줄 알았다. 하지만 당연하게도 나는 올바른 키를 넣은 것이 아니기 때문에, 파일이 복구된 것이 아닌 [그림 5]에서 표시된 가장 마지막 줄의 경우에 해당할 것이다. 다시 말해, 잘못된 키를 넣어 파일이 다시 망가졌다는 것이다. 그래서 ‘file’이 어떻게 되었는지 확인하고자 다시 한번 HxD로 열어보았더니 아래 [그림 6]과 같은 화면을 볼 수 있었다.

[그림 6] HxD로 열어본 'file'(2)


[그림 3]과 [그림 6]을 비교해보면 확실히 다른 파일이 되었음을 알 수 있다. 아무래도 복호화 루틴이 돌긴 돌았는데, 틀린 키를 넣어서 엉뚱한 모습으로 복호화된 듯 하다. 이 가설이 맞는지 확인하기 위해 ‘file’을 새로 가져온 뒤 [그림 5]에서 사용한 키와 다른 키를 넣었더니 [그림 6]과 다른 형태로 변화한 것을 확인할 수 있었다.

따라서, ‘run.exe’은 별도의 키 검증 루틴이 없고 어떤 값을 입력받든 그 값으로 복호화를 돌린다는 결론을 얻을 수 있다. 그렇다면 어떻게 올바른 키를 알아낼 수 있을까? 일단 암호문이라고 할 수 있는 ‘file’을 갖고 있고, 그것이 본래 exe 파일이라는 것을 알고 있으니 일반적인 PE 파일 구조를 평문이라고 할 수 있을 것이다. 그래서 복호화 루틴을 분석해 암호문을 평문으로 만드는 값을 찾을 수 있다면 그 값이 바로 키값이 될 것이다.

그러니 이제 ‘run.exe’를 분석해볼 차례다. 우선 기드라에 집어넣어봤는데 자동 분석 결과가 영 시원찮다. 그래서 왜 이러나 해서 Detect it easy로 파일을 보았더니 아니나 다를까 UPX로 패킹이 되어있었다.

[그림 7] UPX 패킹된 run.exe


정적 분석을 하기 전에는 반드시 패킹을 풀어줘야한다. 다행히게도(?) UPX는 쉽게 풀 수 있는 패킹 방식이다. 푸는 방법은 여러가지가 있겠지만, 아래 [그림 8]과 같이 공식 도구를 사용하면 쉽고 편하게 풀린다.

[그림 8] UPX unpacking


아무튼 패킹을 풀고 다시 기드라에 집어넣었는데… 압축을 풀었음에도 불구하고 에러를 내며 제대로 분석이 되지 않았다. 기드라의 문제는 아닌듯 하고 일단 디버거에서 보기로 하였다.

[그림 9] OllyDbg로 열어본 'run.exe'


run.exe의 EP에는 ‘CALL 44B119’가 찍혀있었다. 이는 정상적인 PE 파일과는 다르다. 프로그램(또는 함수)이 시작되면 제일 먼저 스택 프레임부터 만드는 것이 일반적이다. 하지만 ‘run.exe’는 그런거 없이 바로 0x44B119 함수를 호출해버린다. 결과적으로 ‘run.exe’는 UPX 패킹 뿐만 아니라 별도의 커스텀 패킹이 이뤄진 파일인 듯 하다. 이러니 기드라가 분석을 못할 수밖에.

일단 0x44B119 함수로 들어가보고 실행 흐름을 천천히 따라가보았는데.. 의미 있는 무언가를 얻지는 못했다. 생각해보니, 기드라에서 Defined Strings를 통해 메인 함수를 찾은 것처럼 디버거에서도 같은 방법으로 찾을 수 있었다. 내가 사용한 OllyDbg의 경우, Disassembly 창에서 우클릭 - Search for - All referenced Text strings를 누르면 의미 있는 문자열들만 추려서 보여준다. 해당 기능으로 Unicode 문자열은 볼 수 없는 듯 하지만, [그림 4]를 보면 알 수 있듯 프로그램 내 ‘Key : ‘라는 ASCII 문자열이 들어있고, 이 문자열은 해당 방법을 통해 아래 [그림 10]과 같이 OllyDbg에서 볼 수 있다.

[그림 10] All referenced Text strings


그래서 확인해보니 ‘Key : ‘ 문자열은 44C1AC 주소에서 있는 것을 볼 수 있다. 해당 라인을 클릭하면, 문자열을 참조하는 함수로 바로 이동할 수 있다.

[그림 11] 0x44A775


0x44A775 주소에서 ‘Key : ‘ 문자열을 printf() 함수의 인자로 push하는 것을 볼 수 있다. 그리고 바로 밑에 scanf() 함수를 호출하여 입력받은 키값을 0x44D370에 저장하는 것 또한 볼 수 있다. 입력을 받은 후 fopen(‘file’, ‘rb’)를 호출하는데, 그렇다면 이후에는 ‘file’을 복호화하는 루틴이 시작될 것이다.

[그림 11] 중간에 입력값을 활용한 반복문이 도는데, 최종 반환값이 입력값의 길이인 것으로 보아 strlen()과 유사한 함수인듯 하다.

파일을 불러오는데 성공하면, fseek() 함수로 파일포인터를 파일의 맨 끝으로 보내버린다. 이후 ftell() 함수로 현재 위치값을 반환한다. 참고로 파일 포인터가 맨 끝에 있을 때 ftell()을 호출하면 반환값은 해당 파일의 크기와 동일하다. ftell()은 0x2400을 반환했는데, 해당 값은 ‘file’의 크기인 9216(0x2400) 바이트와 일치한다. 아무튼 해당 값을 EBP-0x10 (0x19FF1C)에 저장하고, rewind() 함수로 파일 포인터를 다시 파일의 맨 처음으로 돌린다.

이후 곧바로 반복문이 시작된다. 정황상 이 부분이 파일을 복호화하는 루틴일 가능성이 높다. 반복문은 총 3개가 주어지는데, 순서에 맞게 먼저 시작되는 반복문부터 분석해보겠다. 해당 반복문은 아래 [그림 12]와 같다.

[그림 12] 복호화 루틴 - 첫 번째 반복문


첫 번째 반복문은 fgetc() 함수로 파일 포인터가 가리키고 있는 위치에서 1바이트를 가져온 후, 해당 데이터를 0x5415B8에서부터 파일크기만큼 쓰는 것을 반복하는 형태이다. 즉, 해당 반복문은 본격적인 복호화 전에 파일 데이터를 메모리에 로드하는 부분인 것이다. 반복문을 모두 마친 후 0x5415B8로 가보면, 아래 [그림 13]과 같은 모습을 확인할 수 있다.

[그림 13] 메모리에 저장된 'file'


즉 엄밀히 말하면 [그림 12]의 반복문은 실제 복호화하는 부분은 아니라는 것이다. 해당 부분을 C로 표현한다면 아래와 같을 것이다.

fp = fopen('file', 'rb');

while (1) {
	if (!feof(fp)) break;
	fgetc(fp);
}

이제 두 번째 반복문으로 넘어가보자. 두 번째 반복문은 아래 [그림 14]와 같다.

[그림 14] 복호화 루틴 - 두 번째 반복문


여기서 드디어 복호화 루틴을 확인할 수 있었다. 우선 ‘file’의 데이터를 맨 처음에서부터 1바이트씩 가져오고, 입력값에서도 1바이트를 가져와 서로 XOR 연산을 한다. 이 때 입력값에서 가져오는 데이터의 위치(offset)는 ‘file’에서 가져오는 데이터의 offset을 입력값의 길이로 나누었을 때의 나머지값과 같다.

예를 들어, 입력값의 길이는 8바이트이고 ‘file’의 1234(0x4D2)번째 데이터를 가져온다고 하자. 이 때 해당 데이터와 XOR 연산을 하는 입력값의 offset은 (1234 % 8 = 2)이다. 이 때 offset은 0부터 시작하므로 입력값의 3번째 글자와 XOR 연산한다고 볼 수 있다.

마지막으로 XOR 연산 된 값을 다시 한번 0xFF와 XOR 연산하여 해당 값을 0x5415B8에서부터 덮어쓰는 것을 볼 수 있다. 이러한 작업을 마찬가지로 파일 크기만큼 반복해 수행한다. 반복문이 끝나면 fclose() 함수로 ‘file’을 닫는다.

정리하자면, 두 번째 반복문이 실제 복호화를 하는 부분임을 알 수 있었고 복호화 공식은 아래와 같음을 알 수 있다.

len_pass = strlen(password)
Encrypted_Data[i] = Data[i] ^ password[i % len_pass] ^ 0xFF;

따라서, XOR 연산의 특성에 따라 다음 식과 같이 복호화된 데이터와 키값을 구할 수 있다.

Data[i] = Encrypted_Data[i] ^ password[i % len_pass] ^ 0xFF;
password[i % len_pass] = Data[i] ^ Encrypted_Data[i] ^ 0xFF;

사실 여기까지만 오더라도 문제를 거의 다 풀은 것과 다름없지만, 일단 아직 반복문이 하나 남았으니 마저 분석해보겠다. 마지막 세 번째 반복문은 아래 [그림 15]와 같다.

[그림 15] 복호화 루틴 - 세 번째 반복문


세 번째 반복문은 두 번째 반복문에서 복호화된 데이터들을 파일에 덮어쓰는 루틴이다. 반복문을 시작하기 전에 fopen(‘file’, ‘wb’)로 파일을 쓸 수 있게 만든 뒤, fputc() 함수로 0x5415B8에서부터 1바이트씩 가져와 파일의 처음부터 끝까지 덮어쓴다. 반복문이 끝나면 [그림 5]에서 보았던 문자열을 출력하는 것을 볼 수 있다.

이제 ‘run.exe’의 분석을 마쳤으니 키값을 계산할 차례다. 위의 식에 따르면, 키값을 얻기 위해서는 평문(Data), 암호문(Encrypted_Data)이 필요하다. 앞서 얘기했듯 우리는 ‘file’이라는 암호문을 갖고 있고, 평문(PE format) 또한 쉽게 구할 수 있다.

따라서, 올바른 키값의 첫 두 바이트(password[0])는 다음과 같이 구할 수 있다.

password[0] = 0x4D ^ 0xDE ^ 0xFF;
password[1] = 0x5A ^ 0xC0 ^ 0xFF;

위 식을 계산하면 0x6C, 0x65이라는 값을 얻을 수 있다. 이런 식으로 계속해서 계산하면 키를 얻을 수 있다. 손으로 풀 수도 있겠지만 나는 Python으로 코드를 짜서 키를 얻어보았다. 생각해보니, 구해야하는 키값의 길이를 모르는 상황이었다. 다시 말해, 정확히 얼만큼 연산을 반복해야 하는지 알 수 없었다. 그래서 일단 가능한 많은 데이터를 긁어왔다. 평문은 ‘run.exe’에서 0x00 ~ 0x3B까지의 데이터를 사용하였고, 암호문은 ‘file’에서 동일한 범위만큼의 데이터를 사용하였다.

Data = [0x4D, 0x5A, 0x90, 0x00, 0x03, 0x00, 0x00, 0x00, 0x04, 0x00,
        0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0xB8, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
Enc_Data = [0xDE, 0xC0, 0x1B, 0x8C, 0x8C, 0x93, 0x9E, 0x86, 0x98, 0x97,
            0x9A, 0x8C, 0x73, 0x6C, 0x9A, 0x8B, 0x34, 0x8F, 0x93, 0x9E,
            0x86, 0x9C, 0x97, 0x9A, 0xCC, 0x8C, 0x93, 0x9A, 0x8B, 0x8C,
            0x8F, 0x93, 0x9E, 0x86, 0x9C, 0x97, 0x9A, 0x8C, 0x8C, 0x93,
            0x9A, 0x8B, 0x8C, 0x8F, 0x93, 0x9E, 0x86, 0x9C, 0x97, 0x9A,
            0x8C, 0x8C, 0x93, 0x9A, 0x8B, 0x8C, 0x8F, 0x93, 0x9E, 0x86]

key = ''

for i in range(len(Data)):
    key += chr(Data[i] ^ Enc_Data[i] ^ 0xFF)
               
print(key)

위의 코드를 돌리면 아래 [그림 16]과 같은 결과를 얻을 수 있다.

[그림 16] The Key


혹시나 키 길이가 0x3C보다 크면 어떡하나 했던 고민이 무색하게 키가 여러차례 반복되어 출력되는 것을 볼 수 있었다. 키의 길이는 13바이트였다. 따라서, 위의 코드에서 Data와 Enc_Data의 첫 13바이트만 사용해도 온전한 키를 구할 수 있다. 이제 해당 키값을 ‘run.exe’에 입력하면 정상적으로 복구된 ‘file’을 볼 수 있을 것이다.

[그림 17] 'file' Decrypted


위 [그림 17]과 같이 정확한 키를 넣었더니 정상적인 exe 파일로 복원된 것을 볼 수 있다. 그렇다면 이제 남은 건 ‘file’의 확장자를 .exe로 고친 후 실행하는 것 뿐이다.

[그림 18] 아니...


MSVCR100D.dll이 없으시단다. 같이 좀 넣어주시지… 해당 파일은 구글링하면 쉽게 구할 수 있다. (악성코드를 받지 않도록 주의할 것) 해당 dll 파일을 ‘file.exe’와 같은 경로에 넣어주고 다시 실행하면…

[그림 19] The Flag


이렇게 심플하게 플래그만 딱 띄워준다. 주황색으로 칠해놓은 부분이 플래그로, 해당 값을 reversing.kr에 입력하면 120점을 얻을 수 있다.

이렇게 Ransomware 문제를 풀어보았다. 나중에 리버싱 기술이 좀 더 늘면 진짜 랜섬웨어도 완전 분석할 수 있겠지…?