앵하니의 더 나은 보안

(reversing.kr) Easy Keygen 본문

보안 기술/ETC

(reversing.kr) Easy Keygen

앵한 2023. 11. 22. 21:59

목적

Easy Keygen 파일을 다운로드 하면 zip파일을 내려받는데, 그 zip파일 안에 ReadMe.txt가 있다.

그러니까 5B134977135E7D13 시리얼에 맞는 적절한 Name 값을 찾으란다.

zip 파일 내 존재하는 Easy_Keygen.exe 프로그램 실행 시 최초 “Input Name“으로 이름 값을 요구하고, 이후 그에 따른 시리얼 값을 요구한다. 이때 Name과 Serial 값이 적절하지 않으면 “Wrong”이라는 메시지가 표시된다.

시리얼 값 5B134977135E7D13에 맞는 이름 값을 정적분석/동적분석을 통해 한번 찾아보자

정적분석

Ghidra로 Easy_Keygen.exe의 분석을 수행한다.


임의의 이름 값을 입력했을때 표시된 “Wrong”을 검색해 찾은 후, 해당 문자열을 어디서 사용하는지 확인한다.

 

“Wrong”을 사용하는 함수 FUN_00401000에 접근한다.

FUN_00401000 접근 및 해당 함수 디컴파일 결과 확인 시 마지막 iVar4 == 0분기에 의해 “Wrong”또는 “Correct”의 출력이 결정되는 것을 알 수 있다.

 

그럼 저 iVar4를 어떻게 0으로 만들 수 있는지 본격적으로 분석을 해보자

 

그른데 막상 기드라로 분석하려니 디컴파일 코드가 너무 지저분하고 알아보기 힘들어서 IDA를 써봤다.
IDA 쓰니까 신세계;
진짜 깔끔하고 알아보기 쉽게 디컴파일 해주더라.

undefined4 FUN_00401000(void)

{
  char cVar1;
  byte bVar2;
  byte *pbVar3;
  int iVar4;
  void *this;
  uint uVar5;
  void *this_00;
  int iVar6;
  byte *pbVar7;
  char *pcVar8;
  undefined4 *puVar9;
  bool bVar10;
  undefined local_12c;
  undefined4 local_12b;
  byte local_c8;
  undefined4 local_c7;
  
  local_12c = 0;
  local_c8 = 0;
  puVar9 = (undefined4 *)(&local_12c + 1);
  for (iVar4 = 0x18; iVar4 != 0; iVar4 = iVar4 + -1) {
    *puVar9 = 0;
    puVar9 = puVar9 + 1;
  }
  *(undefined2 *)puVar9 = 0;
  *(undefined *)((int)puVar9 + 2) = 0;
  puVar9 = &local_c7;
  for (iVar4 = 0x31; iVar4 != 0; iVar4 = iVar4 + -1) {
    *puVar9 = 0;
    puVar9 = puVar9 + 1;
  }
  *(undefined2 *)puVar9 = 0;
  *(undefined *)((int)puVar9 + 2) = 0;
  FUN_004011b9((byte *)s_Input_Name:_00408060);
  FUN_004011a2(this,&DAT_0040805c);
  uVar5 = 0xffffffff;
  iVar4 = 0;
  iVar6 = 0;
  pcVar8 = &local_12c;
  do {
    if (uVar5 == 0) break;
    uVar5 = uVar5 - 1;
    cVar1 = *pcVar8;
    pcVar8 = pcVar8 + 1;
  } while (cVar1 != '\0');
  if (~uVar5 != 1 && -1 < (int)(~uVar5 - 1)) {
    do {
      if (2 < iVar6) {
        iVar6 = 0;
      }
      FUN_00401150((char *)&local_c8,(byte *)s_%s%02X_00408054);
      iVar4 = iVar4 + 1;
      uVar5 = 0xffffffff;
      iVar6 = iVar6 + 1;
      pcVar8 = &local_12c;
      do {
        if (uVar5 == 0) break;
        uVar5 = uVar5 - 1;
        cVar1 = *pcVar8;
        pcVar8 = pcVar8 + 1;
      } while (cVar1 != '\0');
    } while (iVar4 < (int)(~uVar5 - 1));
  }
  puVar9 = (undefined4 *)&local_12c;
  for (iVar4 = 0x19; iVar4 != 0; iVar4 = iVar4 + -1) {
    *puVar9 = 0;
    puVar9 = puVar9 + 1;
  }
  FUN_004011b9((byte *)s_Input_Serial:_00408044);
  FUN_004011a2(this_00,&DAT_0040805c);
  pbVar7 = &local_c8;
  pbVar3 = &local_12c;
  do {
    bVar2 = *pbVar3;
    bVar10 = bVar2 < *pbVar7;
    if (bVar2 != *pbVar7) {
LAB_0040110e:
      iVar4 = (1 - (uint)bVar10) - (uint)(bVar10 != 0);
      goto LAB_00401113;
    }
    if (bVar2 == 0) break;
    bVar2 = pbVar3[1];
    bVar10 = bVar2 < pbVar7[1];
    if (bVar2 != pbVar7[1]) goto LAB_0040110e;
    pbVar3 = pbVar3 + 2;
    pbVar7 = pbVar7 + 2;
  } while (bVar2 != 0);
  iVar4 = 0;
LAB_00401113:
  if (iVar4 == 0) {
    FUN_004011b9((byte *)s_Correct!_00408038);
    return 0;
  }
  FUN_004011b9((byte *)s_Wrong_00408030);
  return 0;
}

🔺 ghidra로 디컴파일한 결과

int __cdecl main(int argc, const char **argv, const char **envp)
{
  signed int v3; // ebp@1
  signed int i; // esi@1
  int result; // eax@6
  char v6; // [esp+Ch] [ebp-130h]@1
  char v7; // [esp+Dh] [ebp-12Fh]@1
  char v8; // [esp+Eh] [ebp-12Eh]@1
  char v9; // [esp+10h] [ebp-12Ch]@1
  char v10; // [esp+11h] [ebp-12Bh]@1
  __int16 v11; // [esp+71h] [ebp-CBh]@1
  char v12; // [esp+73h] [ebp-C9h]@1
  char v13; // [esp+74h] [ebp-C8h]@1
  char v14; // [esp+75h] [ebp-C7h]@1
  __int16 v15; // [esp+139h] [ebp-3h]@1
  char v16; // [esp+13Bh] [ebp-1h]@1

  v9 = 0;
  v13 = 0;
  memset(&v10, 0, 0x60u);
  v11 = 0;
  v12 = 0;
  memset(&v14, 0, 0xC4u);
  v15 = 0;
  v16 = 0;
  v6 = 16;
  v7 = 32;
  v8 = 48;
  sub_4011B9(aInputName);
  scanf(aS, &v9);
  v3 = 0;
  for ( i = 0; v3 < (signed int)strlen(&v9); ++i )
  {
    if ( i >= 3 )
      i = 0;
    sprintf(&v13, aS02x, &v13, *(&v9 + v3++) ^ *(&v6 + i));
  }
  memset(&v9, 0, 0x64u);
  sub_4011B9(aInputSerial);
  scanf(aS, &v9);
  if ( !strcmp(&v9, &v13) )
  {
    sub_4011B9(aCorrect);
    result = 0;
  }
  else
  {
    sub_4011B9(aWrong);
    result = 0;
  }
  return result;
}

🔺 IDA로 디컴파일한 결과

 

그래서 이후부터는 IDA로 정적분석을 수행하겠다.

문맥상 보아하니 sub_4011B9 함수는 단순 printf 관련 함수인듯하다.
sub_4011B9(aInputName); > InputName 출력
sub_4011B9(aCorrect); > Correct 출력
sub_4011B9(aWrong); > Wrong 출력

그리고 사용자가 입력한 name값은 scanf(aS, &v9);를 통해 v9에 저장,
문자열 내 각 문자를

for ( i = 0; v3 < (signed int)strlen(&v9); ++i )
  {
    if ( i >= 3 )
      i = 0;
    sprintf(&v13, aS02x, &v13, *(&v9 + v3++) ^ *(&v6 + i));
  }

연산한 후 v13값에 저장한다.

이후 serial 값을 sub_4011B9(aInputSerial);을 통해 전달받아 v9에 저장한다.

그리고 이름을 연산한 값 v13와 입력한 시리얼 값 v9을 비교, 일치할 경우 Correct를 출력하고
불일치할경우 Wrong을 출력한다.

  if ( !strcmp(&v9, &v13) )
  {
    sub_4011B9(aCorrect);
    result = 0;
  }
  else
  {
    sub_4011B9(aWrong);
    result = 0;
  }

그러니 Correct를 출력하려면 입력한 Name값의 상기 반복문 연산 후 결과 값을 Serial로 입력해주면 되는데,
우리는 이미 5B134977135E7D13라는 시리얼 값을 가지고있으니, 이를 역산해 Name값을 얻어내면 되겠다.

Name 값에 따른 Serial이 어떻게 생성되는지 확인한 후 역산해보자
Name으로 test를 입력했다고 가정, 어떤 시리얼이 나오는지 계산 한 후 맞는지 프로그램 실행을 통해 검증해보겠다.

반복문 진입 전 조건 확인

우선 반복문 내에서 사용되는 변수 값을 확인한다.

line 19 : v13 = 0;
line 26 : v6 = 16;
line 31 : v3 = 0;

그리고 반복문 조건을 확인한다.
line 32 : for ( i = 0; v3 < (signed int)strlen(&v9); ++i )
입력할 “test”의 글자수는 4字이니, strlen(&v9) = 4로 4번의 반복을 수행하면 된다.

그리고 반복문 내 연산에 문자와 숫자의 XOR연산이 존재하므로 ASCII표가 필요하니 첨부하겠다.

 

첫번째 반복

for ( i = 0; v3 < (signed int)strlen(&v9); ++i )
  {
    if ( i >= 3 )
      i = 0;
    sprintf(&v13, aS02x, &v13, *(&v9 + v3++) ^ *(&v6 + i));
  }

 

 

변수
i
0
v3
0

line 34,35 : if ( i >= 3 ) i = 0;
i가 0이니 해당 조건문은 무시된다.

line 36 : sprintf(&v13, aS02x, &v13, *(&v9 + v3++) ^ *(&v6 + i));
각 변수 값을 풀어 다시 작성하면 아래와 같다.
sprintf(&v13, "%s%02x", &v13, *('test'의 주소값 + 0, 즉 ‘t'=0x74) ^ *(16(0x10)데이터의 주소 값+0));
그러니까 v13 문자열에 %s%02x 값을 대입하는데, 첫번째 인자 %s는 v13을 넣음으로써 기존 v13 문자열을 포함시키는 역할을하고, 이후 %02x을 통해 ‘t'(0x74) xor 0x10 연산을 수행, 그 결과 값을 덧붙인다.
기존 v13는 NULL이였으므로, 단순 0x74 xor 0x10 = 0x64의 값이 %02x형태로 v13에 할당된다.

 

v3++에 의해 v3의 값이 그리고 for문의 마지막 i++에 의해 i의 값이 각각 1씩 증가한다.

첫번째 반복문 수행 시 v13(name에 따른 유효한 Serial 값) = ‘64’

 

두번째 반복

for ( i = 0; v3 < (signed int)strlen(&v9); ++i )
  {
    if ( i >= 3 )
      i = 0;
    sprintf(&v13, aS02x, &v13, *(&v9 + v3++) ^ *(&v6 + i));
  }

 

변수
i
1
v3
1

 

line 34,35 : if ( i >= 3 ) i = 0;
i가 1이니 해당 조건문은 무시된다.

line 36 : sprintf(&v13, aS02x, &v13, *(&v9 + v3++) ^ *(&v6 + i));
반복문 두번째 수행 시의 변수 값을 풀어 다시 작성하면 아래와 같다.
sprintf("64"의 주소값, "%s%02x", "64"의 주소값, *('test'의 주소값 + 1, 즉 ‘e'=0x65) ^ *(16데이터의 주소 값+1));
첫번째 반복문 수행때와 변수 값만 달라진채 동일한 연산을 수행하면되는데 마지막에 보면 16데이터의 주소 값+1이라는게 있다. 얘는 뭘 말하는걸까? 숫자 16이면 16이지 메모리 주소상으로 다음 바이트에 존재하는 값을 어떻게 알라고?


이건 최초 선언부를 들여다보면 답이 나온다.
line 6 : char v6; // [esp+Ch] [ebp-130h]@1
line 7 : char v7; // [esp+Dh] [ebp-12Fh]@1
line 8 : char v8; // [esp+Eh] [ebp-12Eh]@1
line26: v6 = 16(0x10);
line27: v7 = 32(0x20);
line28: v8 = 48(0x30);

각 변수는 위와 같이 선언돼있고, v6, v7, v8는 각각 stack메모리 영역에 연속된 위치에 존재하므로
*(&v6+1) = v7 = 0x20을 의미하고,
*(&v6+2) = v8 = 0x30을 의미하게 된다.
여기서는 *(&v6+1)이므로 v7 그러니까 0x20을 의미한다.
이를 토대로 다시 풀어써보자면,
sprintf("64"의 주소값, "%s%02x", "64"의 주소값, 0x65 ^ 0x20); 가 되겠다.
연산 0x65 XOR 0x20의 결과로는 0x45라는 값이 나오므로 v13은 가지고있던 기존 문자 ‘64’에 ‘45’값이 붙어 ‘6445’라는 문자열을 갖게 된다.

마찬가지로 v3++에 의해 v3의 값이 그리고 for문의 마지막 i++에 의해 i의 값이 각각 1씩 증가한다.

두번째 반복문 수행 시 v13(name에 따른 유효한 Serial 값) = '6445’

세번째 반복

for ( i = 0; v3 < (signed int)strlen(&v9); ++i )
  {
    if ( i >= 3 )
      i = 0;
    sprintf(&v13, aS02x, &v13, *(&v9 + v3++) ^ *(&v6 + i));
  }

 

변수
i
2
v3
2


line 34,35 :
if ( i >= 3 ) i = 0;

i가 2이니 해당 조건문은 무시된다.

line 36 : sprintf(&v13, aS02x, &v13, *(&v9 + v3++) ^ *(&v6 + i));
반복문 세번째 수행 시의 변수 값을 풀어 다시 작성하면 아래와 같다.
sprintf("6445"의 주소값, "%s%02x", "6445"의 주소값, *('test'의 주소값 + 2 즉 ‘s'=0x73) ^ *(16데이터의 주소 값+2 즉 v8이므로 0x30을 뜻함));

마찬가지로 0x73 XOR 0x30 값을 연산해 나온 값 0x43을 “6445”에 덧붙여 v13에 할당한다.
세번째 반복문 수행 시 v13(name에 따른 유효한 Serial 값) = '644543’

 

네번째 반복

for ( i = 0; v3 < (signed int)strlen(&v9); ++i )
  {
    if ( i >= 3 )
      i = 0;
    sprintf(&v13, aS02x, &v13, *(&v9 + v3++) ^ *(&v6 + i));
  }

 

변수
i
3
v3
3

 

line 34,35 : if ( i >= 3 ) i = 0;
i가 3이니 해당 조건문을 만족해 i=0으로 다시 할당된다.

변수
i
0
v3
3

line 36 : sprintf(&v13, aS02x, &v13, *(&v9 + v3++) ^ *(&v6 + i));
반복문 네번째 수행 시의 변수 값을 풀어 다시 작성하면 아래와 같다.
sprintf("644543"의 주소값, "%s%02x", "644543"의 주소값, *('test'의 주소값 + 3 즉 ‘t'=0x74) ^ *(16데이터의 주소 값+0 즉 v6이므로 0x10을 뜻함));

i가 다시 0으로 할당돼 다시 v6을 가리키게되고, 입력한 Name 문자와 0x10을 XOR 연산하게 된다.
0x74 XOR 0x10 값을 연산해 나온 값 0x64를 “644543”에 덧붙여 v13에 할당한다.
네번째 반복문 수행 시 v13(name에 따른 유효한 Serial 값) = '64454364’

 

test 시리얼 검증

자 그럼 구한 v13 문자열 ‘64454364’가 ‘test’에 맞는 시리얼값인지 확인해보자

 

 

5B134977135E7D13 역산

자 그럼 어떤식으로 Name에 따른 Serial 값이 생성되는지 파악했으니 반대로 Serial값에 따른 Name을 추정해보자.
시리얼 5B134977135E7D13 값을 두글자 단위로 나누어 각 데이터가 0x10, 0x20, 0x30 중 어떤 바이트와 XOR 연산됐는지 확인한다. 그리고 XOR연산 된 값은 동일한 연산을 했을때 원본 데이터 결과가 나온다는 특성을 이용해 다시 동일한 바이트와 XOR을 연산시켜 원본 문자를 확보, 그에 따른 ASCII 문자 값을 확인해보겠다.

시리얼 문자(바이트단위)
XOR 연산했던 데이터
재연산 결과
재연산 결과에 따른 문자 값
0x5B
0x10
0x4B
K
0x13
0x20
0x33
3
0x49
0x30
0x79
y
0x77
0x10
0x67
g
0x13
0x20
0x33
3
0x5E
0x30
0x6E
n
0x7D
0x10
0x6D
m
0x13
0x20
0x33
3

따라서 5B134977135E7D13 시리얼 값에 맞는 이름 값은 K3yg3nm3가 된다.

 

역산 검증

확인해보자

 

동적분석

유효하지 않은 시리얼 값을 입력했을때 출력되는 “Worng”을 검색해 해당 문자열이 어디서 사용되는지 확인한다.

 

해당 함수의 프롤로그 과정이 존재하는 곳까지 스크롤을 올려 함수 도입부를 특정한다.

 

 

이후 정황상 보이는 문자열들을 통해 과정들을 추측한다.

“Input Name: “ 을 push한 후 4011B9를 호출하는것을 봤을때 출력 관련 함수가 4011B9라고 추측가능하며
마찬가지로 “%s”을 push한 후 4011A2를 호출하는것을 봤을때 입력 관련 함수가 4011A2라고 추측가능하다.

 

이를 토대로 입력한 값이 어디위치에 저장되는지, 어떻게 사용되는지 디버깅을 통해 확인해보자.

 

사용자로부터 값을 입력 받은 후 반복문으로 추정되는 위치에 BreakPoint를 설정한다.

 

이후 프로그램에서 요구하는 Name값으로 “test”입력한다.

 

BreakPoint에 의해 프로그램 동작이 멈추게되고 이때, 입력한 “test”는 esp+0x10 위치에 저장되는것을 알 수 있다.

 

 

그리곤 반복문 내에서 esp+10(사용자가 입력한 “test”)를 사용하는 어셈블리 코드를 확인한다.


이때 ss:[esp+esi+C] = ss:[esp+0+C] = ss:[esp+C]에는 0x10이 저장돼있고,
ss:[esp+ebp+10] = ss:[esp+0+10] = ss:[esp+10]이 돼 사용자가 입력했던 “test”의 “t”값이 저장돼있다.

 

해당 상황을 인지하고서, 어셈블리 코드를 다시 확인해보자

0040107E | 0FBE4C34 0C             | movsx ecx,byte ptr ss:[esp+esi+C]       |
00401083 | 0FBE542C 10             | movsx edx,byte ptr ss:[esp+ebp+10]      |
00401088 | 33CA                    | xor ecx,edx                             |
0040108A | 8D4424 74               | lea eax,dword ptr ss:[esp+74]           |
0040108E | 51                      | push ecx                                |
0040108F | 50                      | push eax                                |
00401090 | 8D4C24 7C               | lea ecx,dword ptr ss:[esp+7C]           |
00401094 | 68 54804000             | push easy keygen.408054                 | 408054:"%s%02X"
00401099 | 51                      | push ecx                                |
0040109A | E8 B1000000             | call easy keygen.401150                 |
0040109F | 83C4 10                 | add esp,10                              |
004010A2 | 45                      | inc ebp                                 |
004010A3 | 8D7C24 10               | lea edi,dword ptr ss:[esp+10]           | [ss:[esp+10]]:EntryPoint
004010A7 | 83C9 FF                 | or ecx,FFFFFFFF                         |
004010AA | 33C0                    | xor eax,eax                             |
004010AC | 46                      | inc esi                                 |
004010AD | F2:AE                   | repne scasb                             |
004010AF | F7D1                    | not ecx                                 |
004010B1 | 49                      | dec ecx                                 |
004010B2 | 3BE9                    | cmp ebp,ecx                             |
004010B4 | 7C C1                   | jl easy keygen.401077                   |

 

0x10과 “test”의 “t”=0x74 각 값을
0040107E | movsx ecx, byte ptr ss:[esp+esi+C]
00401083 | movsx eax, byte ptr ss:[esp+ebp+10]
을 통해 ECX, EDX에 저장시킨다.

이후 00401088 | xor ecx, eax로 0x10과 "t"(0x74)를 XOR 연산한 값을 다시 ECX에 저장한다.

그래서 나온 값 0x64을 ECX에 저장 후 PUSH, 그리고 EAX도 PUSH 한 후 함수 401150을 call한다.
함수 401150이 무슨 역할을 하는 함수인지는 모르겠으나, return 후에는 ESPEAXECX를 XOR한 값이 기록되고, 이후 004010AD | repne scasb(repeat not equal, scan string byte)를 사용해 EDI에 할당된 주소 내 문자열의 문자수만큼 해당 연산을 반복시킨다.
004010AD | repne scasb 연산을 반복할때의 EDI는 “test”를 가리키고 있으니, 4번 반복한다고 이해하면되겠다.

 

4번의 반복을 수행했을때, 반복 수만큼 증가시킨 EBPESI를 이용해 ESP에는 매번 사용자가 입력한 문자열을 한 글자씩 추출한 ASCII 코드 값과, 0x10, 0x20, 0x30을 순차적으로 XOR 연산하는 것을 확인했다.

분류

분류
첫번째 반복 수행
EAX = esp +ebp(0)
test의 t = 0x74
ECX = esp + esi(0) + C
0x10
XOR
0x64
두번째 반복 수행
EAX = esp +ebp(1)
test의 e = 0x65
ECX = esp + esi(1) + C
0x20
XOR
0x45
세번째 반복 수행
EAX = esp +ebp(2)
test의 s = 0x73
ECX = esp + esi(2) + C
0x30
XOR
0x43
네번째 반복 수행
EAX = esp +ebp(3)
test의 t = 0x74
ECX = esp + esi(0) + C
0x10
XOR
0x64

잠깐, 네번째 반복 수행때 ESI는 왜 다시 0이 되는걸까?
이는 중간에 존재하는 cmp esi, 3으로 ESI와 3을 비교한 후 3과 동일할 경우 xor esi, esi 연산을 수행해 ESI를 0으로 만들기 때문이다.

00401077 | 83FE 03                 | cmp esi,3                               |
0040107A | 7C 02                   | jl easy keygen.40107E                   |
0040107C | 33F6                    | xor esi,esi                             |

 


그리고 매 반복문 간 EAXECX를 XOR 연산해 만들어진 각 값들을 16진수형태의 문자로 이어붙여 문자열을 만든다.

 


이후 다시 사용자로부터 serial 값을 요구하는데, 이때 정황상 시리얼값이 “64454364”가 맞겠지만 우선 어떤 검증 과정을 거치는지 확인하기위해 “63444263“을 입력한다.

 

그러고 확인해보면, 마지막 사용자가 입력한 Serial값과 비교시켜 다른값일 경우 xor eax, eax 과정을 건너뛰게 한 다음, test eax, eax 연산을 수행해 zero flag를 set하지 않고, 결국 jne 401130을 통해 401130으로 점프하여 "Wrong" 메시지를 띄우게 된다.

 

자 그럼 어떤식으로 Name 값에서 시리얼 값을 획득하는지 알았으니 역산을 통해 5B134977135E7D13 시리얼 값의 적절한 Name 값을 찾아보자

시리얼 문자(바이트단위) XOR 연산했던 데이터 재연산 결과 재연산 결과에 따른 문자 값
0x5B
0x10
0x4B
K
0x13
0x20
0x33
3
0x49
0x30
0x79
y
0x77
(esi가 3이므로 다시 0으로 초기화) 0x10
0x67
g
0x13
0x20
0x33
3
0x5E
0x30
0x6E
n
0x7D
(esi가 3이므로 다시 0으로 초기화 )0x10
0x6D
m
0x13
0x20
0x33
3

 

5B134977135E7D13 값에 맞는 Name 값은 K3yg3nm3란다.
확인해보자

 

'보안 기술 > ETC' 카테고리의 다른 글

Log4shell(CVE-2021-44228)  (0) 2024.06.23
(reversing.kr) Easy CrackMe  (2) 2023.11.22
AWS Pentesting tool PACU  (0) 2022.11.10
flAWS cloud level 1  (0) 2022.08.03
WhiteHat Contest(2021) Imageflare  (0) 2022.07.18
Comments