codegate2016 ultra_rev

By rls1004 | April 3, 2016

Codegate 2016 ultra_rev 출제자 Write Up

막둥이 선원이 출제 했던 문제로, 예선에 출제 되었던 유일한 로컬서버 문제입니다 :)





출제자의 주절거림


안녕하세요, Ultra_REV 문제 출제자 rls1004입니다.^_^

인증 로그를 직접 보고 싶었는데 로컬 서버를 제공하는 문제이다 보니 서버 모니터링 하기도 바빴네요.

많은 분들이 풀어주실거라 예상했는데 의외로 총 32팀이 풀어주셨습니다.

서버를 모니터링 하면서 보니 문제를 안 읽으신 분decrypt에서 방향을 잘못 잡으신 분들이 많았습니다.

눈에 띄게 열심히 푸시던 분이 계셨는데 굉장히 안타깝습니다.

decrypt 방법은 여러 가지가 있지만 이 write-up에서는 정석적인 두 가지 방법만 소개하겠습니다.

지금부터 침착하게 풀어봅시다.


서버를 날린 관계로 재구성해서 풀었습니다. 풀이 과정에는 영향이 없습니다.




Overview

문제를 읽어봅시다.


# Ultra_REV 문제


당신은 ‘ultra_rev’ 명령어를 사용할 수 있습니다.
이제 서버에 접속해서 파일 리스트를 확인해봅시다.


# 주어진 파일 리스트


encrypt된 파일과 c 소스 파일이 주어졌습니다.

분석에 들어가기 전에 명령어도 한 번 사용해봅시다.


# ultra_rev 명령어 사용1


경로를 입력하라고 나오네요. 현재 경로를 입력해서 다시 실행해 봅시다.


# ultra_rev 명령어 사용2


음.. 그렇다고 하네요. execute_me_enc 파일을 조작해야할 것 같은 느낌이 듭니다.




Analysis

이제 c 소스 코드를 살펴봅시다.


#include
#include
#include

void dec(char *path, char *allData)
{

/*

decrypt your file

allData[a] ^ key[b] ^ key2[c] ^ key3[d]

*/

}

int check(unsigned short a1, int startAddr, unsigned int Size, char *allData)
{

unsigned int v3;

unsigned int v4;

unsigned int v5;

signed int v6;

int v7;

unsigned int v8;

unsigned long long v9;

unsigned int v10;

unsigned int v11;

v4 = Size;

v5 = a1;

v6 = 0;

if( v4 > 1 )

{

v8 = v4;

do

{

v8 -= 2;

v5 += *(unsigned short *)(allData + startAddr);

v9 = (unsigned long long)v5 << 16;

startAddr += 2;

if( !(unsigned short)v8 )

{

v5 = ((int)(((v9)>>32)&0xffffffff)) + ((unsigned int)v9 >> 16);

}

}while(v8 > 1);

v4 = v8;

}

v11 = ((( (v3 >> 16) | ((((((unsigned long long)v5 >> 16) + (unsigned short)v5) >> 16) + ((((unsigned long long)v5 >> 16) + (unsigned short)v5) & 0xffff)) << 16) ) << 16 ) | (((v3 >> 16) | ((((((unsigned long long)v5 >> 16) + (unsigned short)v5) >> 16)+ ((((unsigned long long)v5 >> 16) + (unsigned short)v5) & 0xffff)) << 16)) >> (32-16) ));

v10 = v11;

printf("%x\n",(unsigned short)v10);

return (unsigned short)v10;

}

int main(int argc, char *argv[])
{

int v3, v4, v7, v8, v9, v10, v12, v14, v15, v16, v17, v18, v19, v20, v21;

unsigned int v13;

char v11;

signed int v5;

char path[50];

if( argc < 2 )

{

printf("Usage: ultra_rev [path]\n");

exit(0);

}

strncpy(path,argv[1],30);

strcat(path+strlen(path),"/execute_me_enc");

printf("path : %s\n",path);

FILE *fp = fopen( path, "r+" );

char *allData;

int cnt = 0;

int arraySize;

if(fp == NULL)

{

printf("*** File open error ***\n");

exit(1);

}

allData = (char*)malloc(1);

while(!feof(fp))

{

allData = (char*)realloc(allData, cnt+1);

fscanf(fp, "%c", &allData[cnt]);

cnt++;

}

arraySize = cnt;

v3 = 0x60271070;

v4 = 0;

v5 = arraySize;

v7 = v4;

v8 = v5;

v9 = v5 - 3;

v10 = v4 + 1;

if( *(char *)(allData + v4) == 0x62)

{

if( *(allData + v4) + *(allData + 1) == 210 )

{

if( check(0, v7, v8, allData) == 0xdead )

{

printf("Correct File!\n\n");

dec(argv[1],allData);

return 0;

}

}

}

printf("Not Correct File\n");

return 0;

}


Ultra_REV.c 에는 main, check, dec 이렇게 세 개의 함수가 존재합니다.


  • main함수
    • argv[1]에 입력된 경로에 있는 execute_me_enc 파일을 읽어서 그 데이터(allData)를 몇 가지 조건을 거쳐 check함수에 전달합니다. 
    • check의 반환 값이 0xdead 라면 dec함수에 경로(argv[1])와 데이터(allData)를 전달합니다.

  • check함수
    • 데이터(allData)에 대해 어떠한 연산들을 거쳐 계산한 값을 반환합니다.

  • dec함수
    • dec함수는 내용이 가려져있지만 파일을 decrypt해준다고 쓰여있고, decrypt 방법은 키 값 세 개와의 xor 연산입니다.


정리해보자면 ultra_rev는 execute_me_enc 파일의 데이터가 조건에 맞으면 decrypt를 수행하는 프로그램입니다.

decrypt를 수행하는 방법 두 가지를 소개해드린다고 했었는데요. 
그 첫 번째 방법은 조건을 분석해서 execute_me_enc 파일을 조작하는 것입니다.


check함수를 부르는 main함수를 먼저 분석해보겠습니다.

int v3, v4, v7, v8, v9, v10, v12, v14, v15, v16, v17, v18, v19, v20, v21;

unsigned int v13;

char v11;

signed int v5;

char path[50];


main함수에 선언된 변수들입니다.

굉장히 많은 변수들이 선언되어 있는데,

코드를 잘 보시면 한 번도 불리지 않거나 값을 저장하더라도 사용하지 않는 변수가 굉장히 많습니다.

(조금이나마 헷갈리라고 넣었습니다. notepad++ 등의 프로그램을 사용하시면 쉽게 발견할 수 있습니다.)

그리고 사용하긴 하지만 필요 없는 변수가 있는데요, 어떤 변수에 있는 값을 다른 변수로 옮겨서 그 변수를 사용하는 경우입니다.

이런 변수들을 정리하면,


int main(int argc, char *argv[])

{

char path[50];

/* file open routine */

char *allData;

int arraySize = 0;

...

allData = (char*)malloc(1);

/* file read routine */

if( *(char *)(allData) == 0x62)

{

if( *(allData) + *(allData + 1) == 210 )

{

if( check(0, 0, arraySize, allData) == 0xdead )

{

printf("Correct File!\n\n");

dec(argv[1],allData);

return 0;

}

}

}

printf("Not Correct File\n");

return 0;

}


19개의 변수를 3개의 변수로 줄였고 코드도 간단해졌습니다. 

본격적으로 main함수를 살펴봅시다.

마지막에 3중 if문이 보이는데 조건을 모두 통과해야 dec함수를 부를 수 있습니다. 


  • 첫 번째 조건은 allData의 첫 번째 값이 0x62와 같아야 합니다. 

  • 두 번째 조건은 allData의 첫 번째 값과 두 번째 값을 합한 값이 210과 같아야 합니다. 첫 번째 값이 0x62와 같아야하니 두 번째 값은 210 - 0x62 = 0x70 (112) 가 됩니다. 파일의 첫 2바이트는 ‘bp(0x62 0x70)’여야 하겠네요.

  • 세 번째 조건으로는 check함수의 반환 값이 0xdead와 같아야 합니다.


이제 check 함수를 분석해봅시다.

int check(unsigned short a1, int startAddr, unsigned int Size, char *allData)

{

/* def variable */

v4 = Size;

v5 = a1;

v6 = 0;

if( v4 > 1 )

{

v8 = v4;

do

{

v8 -= 2;

v5 += *(unsigned short *)(allData + startAddr);

v9 = (unsigned long long)v5 << 16;

startAddr += 2;

if( !(unsigned short)v8 )

{

v5 = ((int)(((v9)>>32)&0xffffffff)) + ((unsigned int)v9 >> 16);

}

}while(v8 > 1);

v4 = v8;

}

v11 = ((( (v3 >> 16) | ((((((unsigned long long)v5 >> 16) + (unsigned short)v5) >> 16) + ((((unsigned long long)v5 >> 16) + (unsigned short)v5) & 0xffff)) << 16) ) << 16 ) | (((v3 >> 16) | ((((((unsigned long long)v5 >> 16) + (unsigned short)v5) >> 16)+ ((((unsigned long long)v5 >> 16) + (unsigned short)v5) & 0xffff)) << 16)) >> (32-16) ));

v10 = v11;

printf("%x\n",(unsigned short)v10);

return (unsigned short)v10;

}


굉장히 분석하기 싫게 생겼지만 굉장히 간단한 기능을 수행합니다. 반복문을 먼저 분석해봅시다!

데이터의 사이즈 값(Size)이 1보다 크다면 if문 안으로 들어갑니다.

if문 안의 반복분에서는 allData에서 2바이트씩 가져와서 v5에 계속 더합니다.

남은 데이터의 사이즈(v8)가 0일 때 if문 안으로 들어가게 되는데,

주의하실 점은 데이터의 사이즈는 2씩 감소하기 때문에 사이즈가 홀수인 경우는 if문안으로 절대 들어가지 않습니다. 

파일 사이즈를 확인해볼까요?


# execute_me_enc 파일 크기 확인


ls –al을 사용해서 파일 크기를 확인하면 130964라는 짝수 값을 볼 수 있습니다.


allData = (char*)malloc(1);


main함수에서 파일을 읽을 때 1바이트malloc하고 시작했기 때문에 파일 사이즈는 여기서 1을 더한 값.

즉, 홀수 값이 됩니다. if문을 분석할 필요가 없어졌습니다:)

이제 반복문을 탈출해서 v11에 저장되는 값을 알아봅시다.


v11 = ((( (v3 >> 16) | ((((((unsigned long long)v5 >> 16) + (unsigned short)v5) >> 16) + ((((unsigned long long)v5 >> 16) + (unsigned short)v5) & 0xffff)) << 16) ) << 16 ) | (((v3 >> 16) | ((((((unsigned long long)v5 >> 16) + (unsigned short)v5) >> 16)+ ((((unsigned long long)v5 >> 16) + (unsigned short)v5) & 0xffff)) << 16)) >> (32-16)


그냥 보기엔 복잡하니 반복적으로 나오는 수식을 A로 묶어보도록 하겠습니다.


A = (v3 >> 16) | ((((((unsigned long long)v5 >> 16) + (unsigned short)v5) >> 16) + ((((unsigned long long)v5 >> 16) + (unsigned short)v5) & 0xffff)) << 16)


전체 수식을 A로 표현하면 아래와 같습니다.


v11 = ( (A << 16) | (A >> 16) );


A 수식을 차례대로 계산하면, [v5를 오른쪽으로 16만큼 쉬프트 한 값에 v5를 더하고 그 결과를 다시 오른쪽으로 16만큼 쉬프트한 값][v5를 오른쪽으로 16만큼 쉬프트 한 값에 v5를 더하고 그 결과를 0xffff와 and연산 시킨 값]과 더하고, [그 결과를 왼쪽으로 16만큼 쉬프트한 값]과 [v3를 오른쪽으로 16만큼 쉬프트한 값]을 더합니다.

즉, v5의 상위 2바이트와 하위 2바이트를 더한 값에 0x10000을 곱하고 v3의 상위 2바이트를 더하는 것입니다. v3는 초기화되지 않은 값이라 어떤 값이 들어있을지 모릅니다 :( 어떻게 할까요?


일단 계속 분석해보겠습니다.

A수식의 결과를 왼쪽, 오른쪽으로 각각 16만큼 쉬프트한 값을 or 연산하여 v11에 저장하고 그 값이 v10에 저장되어 반환됩니다.

파일의 데이터를 2바이트씩 더한 값의 상위 2바이트와 하위 2바이트를 더한 값을 0x1234라고 하면, A 수식 결과 0x1234**** 가 나오고 최종 계산된 값은 0x****0000 | 0x00001234 = 0x****1234입니다.


이제 소스 코드 중 return 부분을 보시면 좋은 소식이 있습니다. (unsigned short)로 타입 캐스팅 되어 반환되기 때문에 하위 2바이트만 반환됩니다. v3와 연산된 값이 들어가는 상위 2바이트는 볼 필요가 없습니다.


다시 정리해보자면! check함수는 데이터를 2바이트씩 더한 값에서 상위 2바이트와 하위 2바이트를 더한 2바이트 값을 반환하는 함수입니다. 데이터 중 단 2바이트만 변경하는 것만으로도 0xdead를 맞춰주는 것이 가능합니다 :)

이제 파일을 조작해봅시다.


# ‘bp’ 맞추기


첫 번째와 두 번째 조건인 ‘bp’를 맞춰줬습니다.

여기서 매우매우매우 주의하실 점이 있습니다

 소스코드를 보시면 decrypt 방식으로 xor를 사용한다고 명시해놓았는데,

xor를 사용하기 때문에 데이터의 위치가 변경되면 decrypt시 전혀 다른 값이 나옵니다!

‘bp’를 맞춰 줄 때 바이트를 새로 추가하시면 안 됩니다. 반드시 원래 있던 바이트의 값을 변경하셔야 됩니다.

여기서 실수하신 분들이 굉장히 많았습니다. ㅠ_ㅠ


0xdead를 맞추는 방법은… 프로그램을 짜서 값을 계산해도 되지만 ultra_rev 프로그램에서

현재 파일의 check 값을 출력해주기 때문에 이것을 사용합시다.


# 현재 파일의 check 값


0xdead – 0xAB9E = 0x330F 만큼이 모자라네요.

파일의 제일 뒤에 0x330F를 추가하거나 원래 있던 바이트 중 2바이트에 0x330F를 더하면 됩니다.

이때는 decrypt를 최대한 방해하지 않기 위해 파일의 제일 뒤에 0x330F를 추가하겠습니다.


# 0x330F 추가


다시 실행하면..


# Decrypt complete


decrypt 루틴에 들어가는데 성공했습니다!

여기까지 decrypt의 첫 번째 방법이었습니다. 


decrypt를 하는 두 번째 방법은 코드만 보고는 알 수 없는 방법이긴 하지만 굉장히 간단합니다. 파일을 모두 비우고 ‘bp’를 추가하고 0xdead를 맞춰주도록 최소 크기의 파일을 만들고 decrypt를 수행해도 0x1FFBA만큼이 decrypt 됩니다. 
decrypt방법이 xor였기 때문에 이렇게 decrypt된 데이터를 execute_me_enc의 데이터와 xor해서 직접 execute_me_dec 파일을 만들 수 있습니다.


다음으로 decrypt된 파일을 살펴보겠습니다.


# execute_me_dec


파일 안에 “This program cannot be run in DOS mode”라는 문자열이 보이는데 이는 DOS 스텁 코드에서 흔히 볼 수 있는 문자열입니다. execute_me_dec 파일은 PE파일이네요~

확장자를 .exe로 바꾸고 실행을 시도했지만 실행되지 않습니다. 파일을 다시 살펴보면 시그니쳐가 없네요? 복구해줍시다.


# 시그니쳐 복구


그래도 실행이 안 될 것입니다. 다시 파일을 살펴보시면


# execute_me_dec 파일의 DOS 헤더


DOS 헤더의 마지막 4바이트를 차지하고 있어야할 e_lfanew, 즉 PE 헤더의 시작 위치가 지워져 있는 것을 볼 수 있습니다.

PE 헤더의 시작은 PE 헤더의 시그니쳐인 “PE (0x50 0x45 0x00 0x00)”를 보고 쉽게 찾을 수 있습니다.

e_lfanew를 복구해도 실행이 되지 않는데요, 이쯤 하셨으면 손상된 부분이 더 있구나 하는 느낌이 드셨을 것입니다.

PE 구조에 따라 분석해보면 PE 헤더와 섹션 테이블의 몇몇 값들이 지워져 있는 것을 확인 할 수 있고,

그 바이트들을 복구하면 실행 가능하게 됩니다.

총 17바이트가 지워져있고 모든 값들은 PE 구조 분석을 통해 복구할 수 있습니다. 구체적으로 바꿔야할 값들은 생략하겠습니다.


# 복원된 PE파일


파일을 실행하면 플래그가 나옵니다! 플래그는 “ginsrever_level_up”입니다~

모두 수고하셨습니다!!

comments powered by Disqus