🌏

GhidraでGCC 2025の課題を解いてみよう

2024/12/24に公開

この記事はSecHack365 Advent Calendar 2024の24日目の記事です。

はじめに

Global Cybersecurity Camp (GCC) というプログラムがあります(コンパイラではない)。
「全国大会の次はGCCを目指すと良いよ」という話は聞いていたので、応募開始直後に課題を眺めたところ選抜課題に「実行ファイルから鍵を探して回答せよ」というものがありました。
今までに実行ファイルの解析を行ったことがなく、この1問を見て一旦諦めたのですが、良いタイミングでSecHack365のオフライン回があり、有志によって内部CTFが開催されました。そこでチームメイトがGhidraを使って気合で解析しているのを見て、ぜひ挑戦しようと思いました。
解いた方法が合っているものと信じて、自分のような初心者がどのように解いたかをまとめておこうと思います。ここはもっと効率化できるとか周り道をしているとかがあれば教えていただけると嬉しいです。
この記事の目標は、こういった解析を行う課題への抵抗感を減らし、イベントへの応募数を増やすことです!

用意

Ghidraをインストール
ファイルを用意(ここで使うファイルはGCCの概要ページからダウンロードできます)

解析

Ghidraで逆コンパイルすると、機械的に生成された関数名や変数名がついたコードが出てきます。
適宜GhidraのRename機能を使って、それっぽい名前を付けながらこれらを見て解析を行います。
まずは、ウィンドウ左中央のFilterにmainと入力してmain関数を探しましょう。

コード
__scrt_common_main_seh
/* WARNING: Function: _guard_dispatch_icall replaced with injection: guard_dispatch_icall */
/* Library Function - Single Match
    int __cdecl __scrt_common_main_seh(void)
   
   Library: Visual Studio 2019 Release */

int __cdecl __scrt_common_main_seh(void)

{
  bool bVar1;
  UINT UVar2;
  int iVar3;
  undefined8 uVar4;
  undefined8 uVar5;
  longlong *plVar6;
  ulonglong uVar7;
  ulonglong *puVar8;
  undefined8 *puVar9;
  uint *puVar10;
  undefined8 unaff_RBX;
  undefined8 in_R9;
  
  UVar2 = (UINT)unaff_RBX;
  uVar4 = __scrt_initialize_crt(1);
  if ((char)uVar4 == '\0') {
    FUN_140001f90(7);
  }
  else {
    bVar1 = false;
    uVar4 = __scrt_acquire_startup_lock();
    UVar2 = (UINT)CONCAT71((int7)((ulonglong)unaff_RBX >> 8),(char)uVar4);
    if (DAT_140023060 != 1) {
      if (DAT_140023060 == 0) {
        DAT_140023060 = 1;
        uVar5 = FUN_140008770((undefined8 *)&DAT_1400172b8,(undefined8 *)&DAT_1400172f0);
        if ((int)uVar5 != 0) {
          return 0xff;
        }
        FUN_14000872c((undefined8 *)&DAT_1400172a0,(undefined8 *)&DAT_1400172b0);
        DAT_140023060 = 2;
      }
      else {
        bVar1 = true;
      }
      __scrt_release_startup_lock((char)uVar4);
      plVar6 = (longlong *)FUN_140001f78();
      if ((*plVar6 != 0) && (uVar7 = FUN_140001d34((longlong)plVar6), (char)uVar7 != '\0')) {
        (*(code *)*plVar6)(0,2);
      }
      puVar8 = (ulonglong *)FUN_140001f80();
      if ((*puVar8 != 0) && (uVar7 = FUN_140001d34((longlong)puVar8), (char)uVar7 != '\0')) {
        FUN_140008a80(*puVar8);
      }
      uVar5 = FID_conflict:_get_initial_narrow_environment();
      puVar9 = FUN_140008b3c();
      uVar4 = *puVar9;
      puVar10 = (uint *)FUN_140008b34();
      UVar2 = FUN_140001550((ulonglong)*puVar10,uVar4,uVar5,in_R9);
      uVar7 = FUN_1400020e4();
      if ((char)uVar7 != '\0') {
        if (!bVar1) {
          _cexit();
        }
        __scrt_uninitialize_crt(true,'\0');
        return UVar2;
      }
      goto LAB_140001bfc;
    }
  }
  FUN_140001f90(7);
LAB_140001bfc:
  FUN_140008ac0(UVar2);
  FUN_140008a74(UVar2);
  __security_init_cookie();
  iVar3 = __scrt_common_main_seh();
  return iVar3;
}

Ghidra上では各関数名をダブルクリックするとその関数を開くことができます。しかし、既に関数が多すぎて一つ一つ見るのは面倒です。そこで、今回は実際に実行ファイルを実行して挙動を見てみましょう。(対象がマルウェアの場合、この方法はとりにくい)

実行結果
C:\Users\panda\Documents\GCC2025_FindTheKey>FindTheKey.exe
Input key: hogehoge
Failed! Input correct key!

"Input key:"と"Failed! Input correct key!"という文字列が見えます。標準出力を行っているようなのでprintf()のような関数がこれらのデータを参照しているはずです。Ghidra CodeBrowserのSearch > Memory... でStringを選択し、"input"を検索すると以下のように見つかりました。

3つの文字列ともFUN_140001550から呼ばれていることよりこの関数に飛んでみると、最後の方で"Match~"と"Failed!"の文字データがあるアドレスを参照していることから、このFUN_140001550の処理を追ってみればよさそうです。

コード
FUN140001550
void FUN_140001550(undefined8 param_1,undefined8 param_2,undefined8 param_3,undefined8 param_4)

{
  char *pcVar1;
  byte *pbVar2;
  code *pcVar3;
  ulonglong uVar4;
  _iobuf *p_Var5;
  size_t sVar6;
  byte *pbVar7;
  byte bVar8;
  byte bVar9;
  undefined1 *puVar10;
  uint uVar11;
  undefined uVar12;
  ulonglong uVar13;
  undefined *puVar14;
  ulonglong uVar15;
  ulonglong uVar16;
  undefined auStackY_a8 [32];
  char in_stack_ffffffffffffff78;
  undefined8 in_stack_ffffffffffffff80;
  undefined8 in_stack_ffffffffffffff88;
  undefined8 in_stack_ffffffffffffff90;
  
  uVar4 = DAT_140022008 ^ (ulonglong)auStackY_a8;
  FUN_140001010(0x14001f440,param_2,param_3,param_4);
  uVar12 = (undefined)param_4;
  p_Var5 = (_iobuf *)FUN_1400056f0(0);
  thunk_FUN_140007cc4(&stack0xffffffffffffff78,100,p_Var5);
  sVar6 = FUN_140005820(&stack0xffffffffffffff78,"\n",(char)p_Var5,uVar12,in_stack_ffffffffffffff7 8,
                        in_stack_ffffffffffffff80,in_stack_ffffffffffffff88,
                        in_stack_ffffffffffffff90);
  if (99 < sVar6) {
    __report_rangecheckfailure();
    pcVar3 = (code *)swi(3);
    (*pcVar3)();
    return;
  }
  (&stack0xffffffffffffff78)[sVar6] = 0;
  uVar16 = 0xffffffffffffffff;
  do {
    uVar15 = uVar16 + 1;
    pcVar1 = &DAT_140022a39 + uVar16;
    uVar16 = uVar15;
  } while (*pcVar1 != '\0');
  uVar16 = 0;
  if (uVar15 != 0) {
    do {
      uVar13 = uVar16 % 3;
      bVar8 = (&DAT_140022a38)[uVar16] ^ 0x4b;
      if (uVar13 != 0) {
        bVar8 = (&DAT_140022a38)[uVar16];
      }
      bVar9 = bVar8 ^ 0x65;
      if (uVar13 != 1) {
        bVar9 = bVar8;
      }
      bVar8 = bVar9 ^ 0x59;
      if (uVar13 != 2) {
        bVar8 = bVar9;
      }
      (&DAT_140022a38)[uVar16] = bVar8;
      uVar16 = uVar16 + 1;
    } while (uVar16 < uVar15);
  }
  puVar10 = &DAT_140022a38;
  FUN_140001070((longlong)&stack0xffffffffffffff78,0x140022a38);
  pbVar7 = &DAT_140022a50;
  puVar14 = &stack0xfffffffebffdd528;
  do {
    pbVar2 = pbVar7 + (longlong)puVar14;
    uVar11 = (uint)*pbVar7 - (uint)*pbVar2;
    uVar16 = (ulonglong)uVar11;
    if (uVar11 != 0) break;
    pbVar7 = pbVar7 + 1;
  } while (*pbVar2 != 0);
  // ここで文字データが入っているアドレスを参照している!
  if (uVar11 == 0) {
    FUN_140001070((longlong)&stack0xffffffffffffff78,0x140022a38);
    FUN_140001010(0x14001f450,&stack0xffffffffffffff78,uVar16,puVar14);
  }
  else {
    FUN_140001010(0x14001f470,puVar10,uVar16,puVar14);
  }
  FUN_1400016f0(uVar4 ^ (ulonglong)auStackY_a8);
  return;
}

一通りの処理が終わったあとuVar11==0であれば正解になりそうです。
このコードを下から読んでいくとdo-while文が3つあるので1つずつ見ていきます。
一番下のdo-while文では、pbVar7pbVar14にはそれぞれアドレスが入っているため、それらを加算したpbVar2もアドレスが入っています。do-while文中ではpbVar7が1ずつ加算されていて、pbVar7(なんらかのデータ)のポインタ先とpuVar14(スタック)のポインタ先を先頭から順に1バイトずつ比較していそうです。(変数に良さそうな名前を付けましょう)
次に、ここで参照されているスタックを追っていくと、FUN_140001070に渡されているようです。

コード
FUN_140001070
void FUN_140001070(longlong param_1,longlong param_2)

{
  undefined uVar1;
  byte local_58;
  ulonglong local_50;
  ulonglong local_40;
  ulonglong local_38;
  
  local_40 = 0xffffffffffffffff;
  do {
    local_40 = local_40 + 1;
  } while (*(char *)(param_2 + local_40) != '\0');
  local_38 = 0xffffffffffffffff;
  do {
    local_38 = local_38 + 1;
  } while (*(char *)(param_1 + local_38) != '\0');
  for (local_50 = 0; local_50 < local_38; local_50 = local_50 + 1) {
    uVar1 = *(undefined *)(param_1 + local_50);
    // 意味のない?大量のXOR
    *(byte *)(param_1 + local_50) =
         *(byte *)(param_1 + local_50) ^ DAT_140022a4e ^ DAT_140022a48 ^ DAT_140022a46 ^
         DAT_140022a37 ^ DAT_140022a4e ^ DAT_140022a33 ^ DAT_140022a6f ^ DAT_140022a43 ^
         DAT_140022a47 ^ DAT_140022a32 ^ DAT_140022a31 ^ DAT_140022a4d ^ DAT_140022a46;
    // すぐにuVar1で上書きしている
    *(undefined *)(param_1 + local_50) = uVar1;
    local_58 = *(byte *)(param_1 + local_50) ^ *(byte *)(param_2 + local_50 % local_40);
    if (local_50 % local_40 == 2) {
      local_58 = local_58 ^ DAT_140022a30;
    }
    local_58 = local_58 ^ DAT_140022a47 ^ DAT_140022a4e ^ DAT_140022a48;
    if (local_50 % local_40 == 0) {
      local_58 = local_58 ^ DAT_140022a45;
    }
    local_58 = local_58 ^ DAT_140022a4e ^ DAT_140022a36 ^ DAT_140022a37 ^ DAT_140022a46;
    if (local_50 % local_40 == 1) {
      local_58 = local_58 ^ DAT_140022a71;
    }
    *(byte *)(param_1 + local_50) =
         local_58 ^ DAT_140022a46 ^ DAT_140022a37 ^ DAT_140022a48 ^ DAT_140022a36 ^ DAT_140022a47 ;
  }
  return;
}

この関数のparam_1にはスタックのアドレス、param_2にはアドレス0x140022a38が渡されています。追っていくとlocal_40にはアドレス0x140022a38から始まるデータの長さ、local_38にはスタックに入っているデータの長さが含まれていることがわかります。その後スタックのデータの始点から1バイトずつに対して大量のXORを行ったあと、すぐにuVar1で上書きしていることからこれらのXORは無視できそうです。その後local_58に入力データと0x140022a38からのデータを順番にXORした値を入れ、その後更にmodをとってXORを行ったあとスタックへ上書きしているようです。
ここまでの解析で、このスタックに入力データが入ることはほぼ間違いなさそうなので、あとは0x140022a38からのデータについて分かればSolverが書けそうです。
FUN_140001550の残りの処理を見ていくと、sVar6にスタックのデータ、uVar16にアドレス0x140022a39からのデータの長さが入っていることがわかります。この後、do-while文で0x140022a39からのデータを1バイトずつ区切り、その数字に対してmod3を行いそれぞれ異なるXORを行っています。処理を終えたあとまた同じ場所に書き戻されているのでこれはFUN_140001070で使う鍵を生成しているようです。
これでFUN_140001550の処理の流れを追うことができました。
まとめると

  1. 0x140022a38からのデータを1バイトずつ区切ってそれぞれハードコードされた値とXORして元データに上書きする。
  2. (入力が入っていると考えられる)スタックのデータを1バイトずつ区切って、1.でXORされたデータを用いてXORする。その後modをとって更にXORをしたあと、またXORして元データを上書きする。
  3. 0x140022a38からのデータと処理したスタックのデータを比較する。

後は参照されている先のデータを追って実装することでKeyが求められそうです。頑張りましょう!

これらを実装したPythonのコードが以下になります。

Solver
main.py
def decrypt_key(encrypted_key):
    decrypted_key = []
    for i, byte in enumerate(encrypted_key):
        decrypted_byte = byte
        if (i) % 3 == 0:
            decrypted_byte ^= 0x4B
        if (i) % 3 == 1:
            decrypted_byte ^= 0x65
        if (i) % 3 == 2:
            decrypted_byte ^= 0x59
        decrypted_key.append(decrypted_byte)
    return bytes(decrypted_key)


def decrypt_data(input_array, key_array):
    input_length = len(input_array)
    key_length = len(key_array)

    for cnt0 in range(input_length):
        input_char = input_array[cnt0]
        temp = input_char ^ key_array[cnt0 % key_length]
        if cnt0 % key_length == 2:
            temp ^= 0x7B
        temp ^= 0x3C ^ 0x6D ^ 0x67
        if cnt0 % key_length == 0:
            temp ^= 0x61
        temp ^= 0x6D ^ 0x69 ^ 0x33 ^ 0x77
        if cnt0 % key_length == 1:
            temp ^= 0x6C
        input_array[cnt0] = temp ^ 0x77 ^ 0x33 ^ 0x67 ^ 0x69 ^ 0x3C

    return input_array


encrypted_key = bytearray([0x27, 0x0E, 0x65, 0x0A, 0x41, 0x72, 0x14, 0x03, 0x15, 0x0B])
input_data = bytearray(
    [
        0x54,
        0x68,
        0x32,
        0x61,
        0x54,
        0x4A,
        0x2C,
        0x15,
        0x29,
        0x24,
        0x2D,
        0x40,
        0x04,
        0x02,
        0x04,
        0x19,
        0x6F,
        0x54,
        0x79,
        0x60,
        0x7D,
        0x75,
        0x22,
        0x6C,
        0x50,
        0x4E,
        0x2C,
        0x12,
        0x6D,
        0x61,
        0x00,
    ]
)

key_data = decrypt_key(encrypted_key)
print("Decrypted Data:", decrypt_data(input_data, key_data).decode("ascii"))

結果

これでKeyである "You passed GCC 2025 pre-test!!" を得ることができました。
静的解析を行うのは今回が初めてで一般的なCTFのReversingを解いたことがないため比較はできませんが、おそらく、printf()が必要なところでしか使われていなかったり、そもそも標準入出力に出てくるテキストが最初からバイナリにそのまま埋め込まれていたりと、出題者の優しさを感じました。(不要な処理もありましたがかなりわかりやすいものです。)Reversingとしてはかなり解きやすい問題なんだろうと思います。来年以降も同様の問題が出るかはわかりませんが、ぜひ挑戦しましょう!

P.S. FUN_140001550の最後の部分で正解の方ではもう一度FUN_140001070が呼ばれていますが、これは一度照合のためにXORしたスタックをもう一度同じようにXORして復元するために呼ばれているようですね、最初この部分がよくわかりませんでしたがわかると面白いです。

選考で提出した回答

ここに今年の課題で提出した回答を添付しておきます。

(5-1)FindTheKey.exeをダウンロードし、解析してください。そのプログラムが要求するKeyを見つけ、解答してください。

You passed GCC 2025 pre-test!!

(5-2) 上記のプログラムでKeyを生成するために必要となる暗号/復号鍵(鍵が暗号化されていれば、それを復号した後の文字列)を”すべて”アドレスとともに列挙してください。ただし、Keyの生成に不要なダミーの鍵は含まないように取り除き、Keyの生成に必要な鍵のみを列挙してください。アドレスを記述する際、プログラムを実行するとASLRによってアドレスが変わってしまうため、ベースアドレスには、このプログラムの本来のベースアドレスである0x140000000を用いてください。

プログラムに埋め込まれたKeyとなるデータ
アドレス: 0x140022a50-0x140022a6e
復号結果: You passed GCC 2025 pre-test!!

Keyとなるデータを復号するために必要な鍵
アドレス: 0x140022a38-0x140022a42
復号結果: lk<A$+_fL@

(5-3) どのようにして解いたのかを、技術的に200字以内で答えてください。短すぎる、もしくは長すぎる説明は減点の対象となりますので、ご注意ください。

Ghidraで静的解析を行った。逆コンパイルしたコード全体を読むのは効率的でないため、バイナリ上の標準出力される文字データを参照している関数に注目。その呼び出し元を辿り、プログラムのメイン部分を把握した。解析の結果、鍵生成→鍵を使って入力データを処理→比較と結果出力という流れを確認。このコードをiPad上に表示し、不必要な処理を含めて整理した後、鍵生成と復号を実装して最終的にKeyを取得した。

他のGCC選考課題について

本当はセキュリティ・キャンプ全国大会2024のように全ての課題を公開しようと思っていたのですが、時間があまりなく情報の正確性に自信が持てないためここでは公開しないことにします。もし興味がある方がいらっしゃればTwitterなど何らかの手段で連絡してください!(あとは書いてたらアドベントカレンダーが終わってしまうという事情もある)
どこにも明記はされていないですが、基本的には加点方式の採点だと思うので、書けば書くだけ良いと思います。特に興味のある分野についての設問には思ったことを熱く語りました。

Discussion