プログラム系統備忘録ブログ

記事中のコードは自己責任の下でご自由にどうぞ。

DEF CON CTF 2023 Qualifiers write-up

DEF CON CTF 2023 Qualifiersに参加しました。そのwrite-up記事です。

コンテスト概要

2023/05/27(土) 09:00 +09:00 - 2023/05/29(月) 09:00 +09:00 の48時間の開催でした。他ルールは、ログイン後のトップページに書かれていた内容から引用します:

Information
campaign
CTF chat will be hosted in the DEF CON Discord. Visit https://discord.gg/defcon to join the Discord guild. The cluster of CTF channels should be at the bottom of the list.

Any judgements, changes, or announcements will be displayed on the scoreboard, and also broadcast in the #📣ctf-announcements-text channel. Challenge-specific announcements will also be appended to the challenge clue.

local_fire_department
The game will start with several challenges unlocked, including a "burning" challenge. When a "burning" challenge is solved for the first time, another challenge should unlock automatically.

flag
All answers will be clearly marked as such.
Answers are case-sensitive.
If you see

"flag{23developersdevelopers17586:WhJ5pMTCPivndiXw_f77AW1HYwCNFlyVvTA}"
submit that, including "flag{" at the start and "}" at the end, but without the quotes.

Some challenges require you to present a "ticket" to connect. That ticket (and flag) is traceable to your team and that challenge. Do not share tickets with other teams.

Some challenges may have other ways of giving you the flag; read the descriptions for them.

(後略)

他CTFと比較して、本CTFの特徴的だった点です:

  • 最初は、Welcome問題の他には3問だけが公開されていました。どうやら、「burning」な注意書きがある問題について、いずれかのチームが正解すると、新しい問題が公開されるようでした。最終的には後述のLive CTFを含んで30個弱の問題が公開されました。
  • いくつかの問題は「LiveCTF」と呼ばれるジャンルであり、「指定時間に公開されて、一定時間(殆どの問題は4時間)以内に回答する必要がある」ものでした。
    • 競技期間中はLiveCTF at DEF CON 31 CTF Qualifiersで、配布ファイルのダウンロードや回答提出に使用するAPIの仕様解説がありました。ただどうやら競技期間終了してすぐにドメインが失効したようです……。
  • 多くの問題では、問題個別ページに表示されるTicketを、サーバー接続時等に入力する必要がありました。Ticketは問題やチームごとに異なるらしいです。

結果

Welcome問題と、LiveCTF問題2問を解けました。

環境

WindowsのWSL2(Ubuntu 22.04)を使って取り組みました。

Windows

c:\>ver

Microsoft Windows [Version 10.0.19045.3031]

c:\>wsl -l -v
  NAME                   STATE           VERSION
* Ubuntu-22.04           Running         2
  kali-linux             Stopped         2
  docker-desktop         Running         2
  docker-desktop-data    Running         2

c:\>

他ソフト

  • IDA Free Version 8.2.221215 Windows x64 (64-bit address size) (なお、Free版IDA version 8.2からはx86バイナリもクラウドベースの逆コンパイルができます。version 7頃から引き続き、x64バイナリも同様に逆コンパイルができます。)

WSL2(Ubuntu 22.04)

$ cat /proc/version
Linux version 5.15.90.1-microsoft-standard-WSL2 (oe-user@oe-host) (x86_64-msft-linux-gcc (GCC) 9.3.0, GNU ld (GNU Binutils) 2.34.0.20200220) #1 SMP Fri Jan 27 02:56:13 UTC 2023
$ cat /etc/os-release
PRETTY_NAME="Ubuntu 22.04.2 LTS"
NAME="Ubuntu"
VERSION_ID="22.04"
VERSION="22.04.2 LTS (Jammy Jellyfish)"
VERSION_CODENAME=jammy
ID=ubuntu
ID_LIKE=debian
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
UBUNTU_CODENAME=jammy
$ python3 --version
Python 3.10.6
$ python3 -m pip show pip | grep Version
Version: 22.0.2
$ python3 -m pip show z3-solver | grep Version
Version: 4.8.16.0
$ gdb --version | head -2
GNU gdb (Ubuntu 12.1-0ubuntu1~22.04) 12.1
Copyright (C) 2022 Free Software Foundation, Inc.
$ cat ~/peda/README | grep -e 'Version: ' -e 'Release: '
Version: 1.0
Release: special public release, Black Hat USA 2012
$ docker --version
Docker version 20.10.24, build 297e128
$

解けた問題

[intro] Welcome to Quals (517 solves)

問題説明文として、Host、Portのみが記載されていました。とりあえずncコマンドで接続してみました:

$ nc omitted 10001
Ticket please: ticket{omitted}
Hello challenger, enter your payload below:
testtest
sh: 1: grfggrfg: not found

payloadを入力したら、入力を変換した結果であるgrfggrfgを実行しようとしてくれたようです。ROT13であるように見えたので、shをROT13変換したfuを入力してみました:

$ nc omitted 10001
Ticket please: ticket{omitted}
Hello challenger, enter your payload below:
fu
ls
challenge
run_challenge.sh
ls /
bin
boot
dev
etc
home
lib
lib32
lib64
libx32
media
mnt
opt
proc
root
run
sbin
srv
sys
tmp
usr
var
welcome_flag.txt
cat /wel*
flag{omitted}Timeout!

プロンプトこそ表示されませんが無事にシェルを起動できました。なお、10秒ほどでタイムアウトするため素早く入力する必要がありました。フラグ内容は特段意味はなさそうなので省略しています。

[LiveCTF] What a maze meant (40 solves)

配布ファイルとして、サーバー側プログラムや、解答テンプレート、動作確認用シェルスクリプト等がありました:

$ find . -type f -print0 | xargs -0 file
./exploit/Dockerfile:           ASCII text
./exploit/solve-template.py:    Python script, ASCII text executable
./handout/challenge:            ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=7b2b5dca26c3e0700cff8a15877c41399c95bd9f, for GNU/Linux 3.2.0, not stripped
./handout/config.toml:          ASCII text
./handout/Dockerfile:           ASCII text
./handout/ld-linux-x86-64.so.2: ELF 64-bit LSB shared object, x86-64, version 1 (GNU/Linux), dynamically linked, BuildID[sha1]=61ef896a699bb1c2e4e231642b2e1688b2f1a61e, stripped
./handout/libc.so.6:            ELF 64-bit LSB shared object, x86-64, version 1 (GNU/Linux), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=69389d485a9793dbe873f0ea2c93e02efaa9aa3d, for GNU/Linux 3.2.0, stripped
./test-solution.sh:             POSIX shell script, ASCII text executable
$

handout/challengeをIDAで開いて逆コンパイルして読み進めていくと、平面迷路をゴールまで移動するゲームだと分かりました。

問題の理解

IDAで逆コンパイルして、ローカル変数名を編集した状態のmain関数内容です:

int __cdecl main(int argc, const char **argv, const char **envp)
{
  unsigned int dwTime; // eax
  char charInputed; // [rsp+Bh] [rbp-3A5h] BYREF
  int i; // [rsp+Ch] [rbp-3A4h]
  int j; // [rsp+10h] [rbp-3A0h]
  unsigned int currentX; // [rsp+14h] [rbp-39Ch]
  unsigned int currentY; // [rsp+18h] [rbp-398h]
  int bShowRandomDescription; // [rsp+1Ch] [rbp-394h]
  char maze[904]; // [rsp+20h] [rbp-390h] BYREF
  unsigned __int64 v12; // [rsp+3A8h] [rbp-8h]
  __int64 savedregs; // [rsp+3B0h] [rbp+0h] BYREF

  v12 = __readfsqword(0x28u);
  setvbuf(stdin, 0LL, 2, 0LL);
  setvbuf(stdout, 0LL, 2, 0LL);
  dwTime = time(0LL);
  srand(dwTime);
  for ( i = 0; i <= 29; ++i )
  {
    for ( j = 0; j <= 29; ++j )
      *((_BYTE *)&savedregs + 30 * i + j - 912) = '#';
  }
  puts("Reticulating splines... ");
  generate_maze((BYTE *)maze, 1, 1, 1);
  puts("\n\nWelcome to the maze!");
  currentX = 1;
  currentY = 1;
  bShowRandomDescription = 1;
  while ( 1 )
  {
    if ( show_maze )
      display_maze(maze, currentX, currentY);
    printf("You are in room (%d, %d)\n", currentX, currentY);
    if ( bShowRandomDescription )
      randomDescription();
    else
      bShowRandomDescription = 1;
    puts("Which would you like to do?");
    if ( validwalk(*((_BYTE *)&savedregs + 30 * (int)(currentX - 1) + (int)currentY - 912)) )
      printf("go (n)orth, ");
    if ( validwalk(*((_BYTE *)&savedregs + 30 * (int)(currentX + 1) + (int)currentY - 912)) )
      printf("go (s)outh, ");
    if ( validwalk(*((_BYTE *)&savedregs + 30 * (int)currentX + (int)(currentY - 1) - 912)) )
      printf("go (w)est, ");
    if ( validwalk(*((_BYTE *)&savedregs + 30 * (int)currentX + (int)(currentY + 1) - 912)) )
      printf("go (e)ast, ");
    printf("or (q) end the torment");
    printf(": ");
    __isoc99_scanf(" %c", &charInputed);
    putchar('\n');
    switch ( charInputed )
    {
      case 'a':
        puts("You cast arcane eye and send your summoned magical eye above the maze.");
        show_maze = 1;
        bShowRandomDescription = 0;
        break;
      case 'e':
        if ( validwalk(*((_BYTE *)&savedregs + 30 * (int)currentX + (int)(currentY + 1) - 912)) )
          ++currentY;
        break;
      case 'n':
        if ( validwalk(*((_BYTE *)&savedregs + 30 * (int)(currentX - 1) + (int)currentY - 912)) )
          --currentX;
        break;
      case 'q':
        exit(0);
      case 's':
        if ( validwalk(*((_BYTE *)&savedregs + 30 * (int)(currentX + 1) + (int)currentY - 912)) )
          ++currentX;
        break;
      case 'w':
        if ( validwalk(*((_BYTE *)&savedregs + 30 * (int)currentX + (int)(currentY - 1) - 912)) )
          --currentY;
        break;
      default:
        break;
    }
    if ( *((_BYTE *)&savedregs + 30 * (int)currentX + (int)currentY - 912) == '*' )
    {
      if ( rand() % 1213 == 1212 )
      {
        puts("You successfully exit the maze!");
        winner();                               // この中で/bin/shを起動している。ここに到達できれば実質クリア。
      }
      puts("Just as you are about to exit, a displacer beast captures you. You die.");
      exit(0);
    }
  }
}

シンボル情報残っていて関数名やグローバル変数名がそのまま表示されたので読み進めやすかったです。一部&savedregs ... - 912というよくわからない変数アクセスがありますが、文脈から迷路情報を格納する文字配列変数へのアクセスとエスパーできます。ひとまずいくつかのことが分かりました:

  • 上下左右に移動するコマンド以外に、隠しコマンドとしてaコマンドがあります。それによって迷路全体を表示できそうです。
  • 迷路のゴールまで到達しても、rand() % 1213 == 1212の場合だけクリアでき、そうでない場合は失敗になります。

危険な処理もなさそうだったので、試しに実行してみました:

$ ./challenge
Reticulating splines...
/

Welcome to the maze!
You are in room (1, 1)
As you step into the room, you find yourself standing in a massive space. The walls are adorned with beds and a two large chess sets dominate the center of the room. You see 19 flowers in a vase, and through a window you stop to count 24083 stars. The room appears well designed in the Rustic style.
Which would you like to do?
go (e)ast, or (q) end the torment: a

You cast arcane eye and send your summoned magical eye above the maze.
#############################
#@......#.....#...........#.*
#######.###.#.###.#.#####.#.#
#.....#...#.#...#.#.....#...#
#.###.###.#.###.#######.###.#
#...#.#...#...#.......#.#...#
###.###.#####.#######.#.#.###
#...#...#...#.#.....#...#...#
#.#.#.###.#.#.#.###########.#
#.#.#.....#...#...........#.#
#.#.###########.#######.#.#.#
#.#...........#.#.......#.#.#
#.###########.#.#.#########.#
#...#.....#...#.#.......#...#
#.#.###.###.###########.#.###
#.#...#.....#.......#...#.#.#
#.###.#.#####.#####.#.#.#.#.#
#.#...#.......#...#...#.#...#
#.#.#####.#####.#.#####.###.#
#.#.#...#.#...#.#...#.#.#.#.#
#.#.#.#.#.#.#.###.#.#.#.#.#.#
#.#.#.#...#.#...#.#...#...#.#
#.#.#######.###.#.#####.###.#
#.#...#.......#.#.......#...#
#.###.#.#######.#####.###.###
#...#.#...#.....#...#...#.#.#
###.#.###.#.#####.#.#####.#.#
#...#.....#.......#.........#
#############################

You are in room (1, 1)
Which would you like to do?
go (e)ast, or (q) end the torment: e

#############################
#.@.....#.....#...........#.*
#######.###.#.###.#.#####.#.#
#.....#...#.#...#.#.....#...#
#.###.###.#.###.#######.###.#
#...#.#...#...#.......#.#...#
###.###.#####.#######.#.#.###
#...#...#...#.#.....#...#...#
#.#.#.###.#.#.#.###########.#
#.#.#.....#...#...........#.#
#.#.###########.#######.#.#.#
#.#...........#.#.......#.#.#
#.###########.#.#.#########.#
#...#.....#...#.#.......#...#
#.#.###.###.###########.#.###
#.#...#.....#.......#...#.#.#
#.###.#.#####.#####.#.#.#.#.#
#.#...#.......#...#...#.#...#
#.#.#####.#####.#.#####.###.#
#.#.#...#.#...#.#...#.#.#.#.#
#.#.#.#.#.#.#.###.#.#.#.#.#.#
#.#.#.#...#.#...#.#...#...#.#
#.#.#######.###.#.#####.###.#
#.#...#.......#.#.......#...#
#.###.#.#######.#####.###.###
#...#.#...#.....#...#...#.#.#
###.#.###.#.#####.#.#####.#.#
#...#.....#.......#.........#
#############################

You are in room (1, 2)
As you step into the room, you find yourself standing in a medium-sized space. The walls are adorned with beds and a two large tapestries dominate the center of the room. You see 17 flowers in a vase, and through a window you stop to count 46065 stars. The room appears well designed in the Country style.
Which would you like to do?
go (w)est, go (e)ast, or (q) end the torment: q

$

自分の位置は@文字で、ゴールは*文字で表されるようです。

これまでの状況から、以下2つのタスクを達成する必要があると分かりました:

  • 迷路を解いてゴールまで到達する。
    • aコマンドで迷路全体がわかるので、後は幅優先探索と経路復元でできそうです。そのためこちらは問題ありません。
      • 別に最短経路を通る必要は無さそうなので、右手法などでも解けると思います。
  • ゴール時の追加判定rand() % 1213 == 1212をどうにかして達成する。
    • 「何度も提出して、偶然正解を引けるまで粘る」手を考えはしたのですが、サーバーへの攻撃等と見なされないか心配になったので避けました。

本問題の以降の解説は、2つ目のタスク「ゴール時の追加判定をどうにかして達成する」方法の解説になります。

最後のrand関数結果の取得

上記のmain関数の中で、1回コマンドを入力するたびにrandomDescription関数が呼び出されています。当該関数は以下の内容です:

unsigned __int64 randomDescription()
{
  int dwRandomValue; // eax
  char *v2[4]; // [rsp+20h] [rbp-130h]
  char *v3[16]; // [rsp+40h] [rbp-110h]
  char *v4[16]; // [rsp+C0h] [rbp-90h]
  unsigned __int64 v5; // [rsp+148h] [rbp-8h]

  v5 = __readfsqword(0x28u);
  v2[0] = "cozy";
  v2[1] = "medium-sized";
  v2[2] = "spacious";
  v2[3] = "massive";
  v3[0] = "bookshelves";
  v3[1] = "fireplaces";
  v3[2] = "suits of armor";
  v3[3] = "tables";
  v3[4] = "chests";
  v3[5] = "beds";
  v3[6] = "paintings";
  v3[7] = "statues";
  v3[8] = "tapestries";
  v3[9] = "candelabras";
  v3[10] = "chairs";
  v3[11] = "fountains";
  v3[12] = "mirrors";
  v3[13] = "rugs";
  v3[14] = "curtains";
  v3[15] = "chess sets";
  v4[0] = "Art Deco";
  v4[1] = "Baroque";
  v4[2] = "Classical";
  v4[3] = "Colonial";
  v4[4] = "Contemporary";
  v4[5] = "Country";
  v4[6] = "Gothic";
  v4[7] = "Industrial";
  v4[8] = "Mediterranean";
  v4[9] = "Minimalist";
  v4[10] = "Neoclassical";
  v4[11] = "Renaissance";
  v4[12] = "Rococo";
  v4[13] = "Romantic";
  v4[14] = "Rustic";
  v4[15] = "Victorian";
  ++random_counter;
  dwRandomValue = rand();
  printf(
    "As you step into the room, you find yourself standing in a %s space. The walls are adorned with %s and a two large %"
    "s dominate the center of the room. You see %d flowers in a vase, and through a window you stop to count %d stars. Th"
    "e room appears well designed in the %s style.\n",
    v2[dwRandomValue & 3],
    v3[(dwRandomValue >> 2) & 0xF],
    v3[(dwRandomValue >> 6) & 0xF],
    (dwRandomValue >> 14) & 0x1F,
    (unsigned int)(dwRandomValue >> 14),
    v4[(dwRandomValue >> 10) & 0xF]);
  return v5 - __readfsqword(0x28u);
}

つまり、rand関数結果をいくつかのビット範囲に分割して、分割結果を標準出力に使用している事が分かります。特に%d stars箇所では(32-14の)上位18ビット全てが現れます。これらから、標準出力内容からrand関数結果の全ビットを復元できそうだと分かりました。

次のrand関数結果の予測

challengeバイナリではrand関数は動的リンクしているため実装は含まれていませんが、今回はlibc.so.6も配布されています。そちらをIDAで見て実装を追うと、最終的にrandom_r関数で乱数を生成している事が分かりました:

__int64 __fastcall random_r(__int64 a1, _DWORD *a2)
{
  unsigned int v2; // r8d
  _DWORD *v3; // rax
  int v4; // edx
  __int64 result; // rax
  _DWORD *v6; // r8
  unsigned __int64 v7; // r9
  unsigned int v8; // edx
  unsigned __int64 v9; // rcx
  _DWORD *v10; // r8
  __m128i v11; // xmm0

  if ( !a1 || !a2 )
  {
    v2 = -1;
    errno = 22;
    return v2;
  }
  v2 = *(_DWORD *)(a1 + 24);
  v3 = *(_DWORD **)(a1 + 16);
  if ( !v2 )
  {
    v4 = (1103515245 * *v3 + 12345) & 0x7FFFFFFF;
    *v3 = v4;
    *a2 = v4;
    return v2;
  }
  v6 = *(_DWORD **)(a1 + 8);
  v7 = *(_QWORD *)(a1 + 40);
  v8 = **(_DWORD **)a1 + *v6;
  v9 = *(_QWORD *)a1 + 4LL;
  v10 = v6 + 1;
  *(_DWORD *)(v9 - 4) = v8;
  *a2 = v8 >> 1;
  if ( v7 > v9 )
  {
    if ( v7 <= (unsigned __int64)v10 )
      v10 = v3;
    v3 = (_DWORD *)v9;
  }
  v11 = _mm_unpacklo_epi64((__m128i)(unsigned __int64)v3, (__m128i)(unsigned __int64)v10);
  result = 0LL;
  *(__m128i *)a1 = v11;
  return result;
}

v4 = (1103515245 * *v3 + 12345) & 0x7FFFFFFFな線形合同法を使う場合と、よく分からない方法で次の値を得る場合があるようです。デバッガーで確認すると、その分岐で使うv2の値は3になっていました。そのため「よく分からない方法」で今回は乱数が生成されていることが分かりました。(線形合同法だったら次の値を簡単に予測できたのに……!)

random関数の実装をGoogle検索していると、c - glibc rand function implementation - Stack Overflowglibc/random_r.c at master · lattera/glibc · GitHubを見つけました。それらによると、今回のrandom_r関数ではTYPE_3というモードを使っているらしいことが分かりました。

「なんとかしてTYPE_3rand実装の次の値を予測できる方法は無いものか」と色々ググっていると、gnu "rand" "TYPE_3" pythonのキーワードでググったときにZ3Pyでglibc rand(3)の出力を推測してみる - ももいろテクノロジーを見つけました。z3ライブラリを使用すると予測できるとのことです。また、当該記事中ではTYPE_3マクロのみ記載があるため、TYPE_3の場合の乱数を予測できそうです。試しに今回の問題の出力乱数列を与えてみると、正しく次の値を予測できました!

また、randomDescription関数は、移動に有効なコマンドを入力した場合以外にも、x等の無意味なコマンドを入力した場合にも実行されます。そのためゴールの1歩手前まで移動した後に、xコマンドを連打すると、残り1歩でゴールできる状況のまま乱数を進められます。次の乱数がrand() % 1213 == 1212を満たすまで乱数を進めてからゴールすれば良さそうです。

総まとめ

これまで分かったことを実装したものが以下のコードです(実際は迷路の探索、現在乱数の取得、次の乱数の予測、の順番に実装していきました):

#!/usr/bin/env python3

import os
import pwn
import queue
import time
import re
import z3

pwn.context.log_level = "CRITICAL" # suppress connection log

HOST = os.environ.get('HOST', 'localhost')
PORT = 31337

MAZE_SIZE = 29
dx = [1, 0,-1, 0]
dy = [0, 1, 0,-1]
INF = 0x3F3F3F3F

def read_maze(io):
    line_list = []
    for height in range(MAZE_SIZE):
        line_list.append(io.readline().decode().strip())
    return line_list

def get_position(maze, character):
    for (y, line) in enumerate(maze):
        x = line.find(character)
        if x > 0:
            return (x, y)
    raise Exception(f"Can not found position for '{character}'!")

def parse_current_random_value_from_description(description):
    m = re.search(r"As you step into the room, you find yourself standing in a ([\w -]+) space. The walls are adorned with ([\w ]+) and a two large ([\w ]+) dominate the center of the room. You see (\d+) flowers in a vase, and through a window you stop to count (\d+) stars. The room appears well designed in the ([\w ]+) style.", description)
    assert m is not None
    v2 = [
        "cozy",
        "medium-sized",
        "spacious",
        "massive",]
    v3 = [
        "bookshelves",
        "fireplaces",
        "suits of armor",
        "tables",
        "chests",
        "beds",
        "paintings",
        "statues",
        "tapestries",
        "candelabras",
        "chairs",
        "fountains",
        "mirrors",
        "rugs",
        "curtains",
        "chess sets",]
    v4 = [
        "Art Deco",
        "Baroque",
        "Classical",
        "Colonial",
        "Contemporary",
        "Country",
        "Gothic",
        "Industrial",
        "Mediterranean",
        "Minimalist",
        "Neoclassical",
        "Renaissance",
        "Rococo",
        "Romantic",
        "Rustic",
        "Victorian",
        ]

    def get_index(l, v):
        i = l.index(v)
        assert i >= 0
        return i
    b0 = get_index(v2, m.group(1))
    b1 = get_index(v3, m.group(2)) << 2
    b2 = get_index(v3, m.group(3)) << 6
    b3 = int(m.group(4)) << 14
    b4 = int(m.group(5)) << 14
    b5 = get_index(v4, m.group(6)) << 10
    value = b0 | b1 | b2 | b3 | b4 | b5
    return value

def get_steps_from_goal(maze, min_steps, initial_position, final_position):
    command_list = []
    (cx, cy) = final_position
    while (cx, cy) != initial_position:
        for i in range(4):
            px = cx - dx[i]
            py = cy - dy[i]
            if not (0 <= px < MAZE_SIZE and 0 <= py < MAZE_SIZE): continue

            if min_steps[py][px] + 1 != min_steps[cy][cx]: continue
            (cx, cy) = (px, py)
            command_list.append("eswn"[i])
            break
        else:
            raise Exception(f"Cannot recover steps! {cx = }, {cy = }")
    return command_list[::-1]

def find_steps(maze):
    initial_position = get_position(maze, "@")
    final_position = get_position(maze, "*")
    min_steps = [[INF for i in range(MAZE_SIZE)] for j in range(MAZE_SIZE)]

    q = queue.Queue()
    q.put((initial_position[0], initial_position[1], 0))
    while not q.empty():
        (cx, cy, ct) = q.get(block=False)
        if min_steps[cy][cx] <= ct: continue
        min_steps[cy][cx] = ct

        # print(f"{cx =} , {cy = }, {ct = }")
        if maze[cy][cx] == "*":
            return get_steps_from_goal(maze, min_steps, initial_position, final_position)

        for i in range(4):
            nx = cx + dx[i]
            ny = cy + dy[i]
            if not (0 <= nx < MAZE_SIZE and 0 <= ny < MAZE_SIZE): continue
            if maze[ny][nx] == "#": continue
            q.put((nx, ny, ct+1))
    raise Exception("Cannot solve maze!")

def crack_random_value_and_goal_when_could_got_shell(io, last_command):
    def read_description_value(io):
        io.sendlineafter(b"or (q) end the torment:", "x".encode())
        description = io.recvline_contains(b"As you step into the room, you find yourself standing in").decode()
        return parse_current_random_value_from_description(description)

    # print("Cracking...")
    # https://inaz2.hatenablog.com/entry/2016/03/07/194000
    state = [z3.BitVec("state%d" % i, 32) for i in range(31)]
    s = z3.Solver()
    for i in range(93):
        state[(3+i) % 31] += state[i % 31]
        output = (state[(3+i) % 31] >> 1) & 0x7fffffff
        actual = read_description_value(io)
        s.add(output == actual)

    if s.check() != z3.sat:
        raise Exception("Can not solve by Z3!")
    m = s.model()

    # verify
    for i in range(93, 93+31):
        state[(3+i) % 31] += state[i % 31]
        output = m.evaluate((state[(3+i) % 31] >> 1) & 0x7fffffff)
        actual = read_description_value(io)
        # print(output, actual, m.evaluate(output == actual))

    # consume until rand will be 1312
    for i in range(93+31, 93+31+100000):
        state[(3+i) % 31] += state[i % 31]
        output = m.evaluate((state[(3+i) % 31] >> 1) & 0x7fffffff)
        if int(str(output)) % 1213 == 1212:
            io.sendlineafter(b"or (q) end the torment:", last_command.encode())
            return
        # consume it
        actual = read_description_value(io)
        # print(output, actual, m.evaluate(output == actual))

def solve(io_factory):
    with io_factory() as io:
        io.sendlineafter(b"or (q) end the torment:", b"a")
        io.recvuntil(b"You cast arcane eye and send your summoned magical eye above the maze.\n")
        maze = read_maze(io)
        steps = find_steps(maze)
        for command in steps[:-1]: # go to a 1steps from target
            io.sendlineafter(b"or (q) end the torment:", command.encode())

        crack_random_value_and_goal_when_could_got_shell(io, steps[-1])

        prompt = io.clean(timeout=0.1)
        if b"You successfully exit the maze!" in prompt:
            io.sendline(b"./submitter") # "Unless otherwise specified, the goal of each challenge is to execute "./submitter" which will print the flag. Your solution should then output the flag to stdout."
            time.sleep(0.5)
        print(io.clean().decode())

io_factory = lambda: pwn.remote(HOST, int(PORT))
# io_factory = lambda: pwn.process("../handout/challenge")

solve(io_factory)

その他、配布されている状態のDockerfileテンプレートに、z3パッケージをインストールするコマンドを追加しました:

FROM livectf/livectf:quals-exploit

RUN python3 -m pip install z3-solver
COPY solve.py /

WORKDIR /
CMD ["python3", "solve.py"]

提出手順に従って、上記2ファイルをtarでまとめたファイルを提出すると正解できました。

以下、本問題を解く上でハマったところです:

  • 最初、rand関数は線形合同法を使うものと思いこんでいました。そのため逆コンパイル結果で見えた線形合同法パラメーターで次の値を予測しようとして、予測値と実測値が合わずに困っていました。デバッガーで確認してようやく、線形合同法ではないものを使っていると分かりました。
  • シェルを取った後に何をしたらいいのかしばらく分かりませんでした。LiveCTFページを読み直して、./submitterを実行すれば良いと分かりました。
  • 手元実行では問題なく実行できているはずなのに、サーバーへ提出しても不正解になっていました。余分な出力を抑制していくと正解になりました。よく分かりません。

[LiveCTF] Cheese and Crackers (16 solves)

A new state-of-the-art obfuscation framework just dropped. Can you write a tool to automatically defeat it?

こちらも同様に、配布ファイルとして、サーバー側プログラムや、解答テンプレート、動作確認用シェルスクリプト等がありました:

$ find . -type f -print0 | xargs -0 file
./exploit/Dockerfile:                                   ASCII text
./exploit/solve-template.py:                            Python script, ASCII text executable
./handout/challenge:                                    POSIX shell script, ASCII text executable
./handout/config.toml:                                  ASCII text
./handout/Dockerfile:                                   ASCII text
./handout/generator_dist.py:                            Python script, ASCII text executable
./handout/run.sh:                                       POSIX shell script, ASCII text executable
./handout/samples/07402ae4f06297c77c99599067b46a79.elf: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, stripped
(省略、全部で20個のサンプルELFがあります)
./handout/samples/f7785e233f41c48fa4b767f23e244408.elf: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, stripped
./handout/server.py:                                    Python script, ASCII text executable
./test-solution.sh:                                     POSIX shell script, ASCII text executable
$

この問題のhandout/challengeは、server.pyを実行しているだけです:

$ cat handout/challenge
#!/bin/sh

python3 server.py
$

server.pyは、以下の内容です:

#!/usr/bin/env python3

import base64
import os
import select
import sys

if os.environ.get('LOCAL', False):
    from generator_dist import get_challenge_binary
else:
    from generator_server import get_challenge_binary

NUM_ROUNDS = 20
ROUND_TIMEOUT = 10.0

def do_round() -> bool:
    challenge = get_challenge_binary()
    if not challenge:
        raise RuntimeError('Failed to build challenge. Please contact an administrator.')
    challenge_data, challenge_password = challenge
    challenge_b64 = base64.b64encode(challenge_data).decode()

    print(f'Crackme: {challenge_b64}')
    # Using input() for prompt prints to stderr for non-TTY
    print('Password: ', end='', flush=True)
    if not (stdin := select.select([sys.stdin], [], [], ROUND_TIMEOUT)[0]):
        return False

    user_password = stdin[0].readline().strip()
    return challenge_password == user_password


def do_game(num_rounds: int) -> bool:
    for round_number in range(num_rounds):
        print(f'Round {round_number+1}/{num_rounds}')
        round_result = do_round()
        if round_result:
            print('Correct!')
        else:
            print('Incorrect! Goodbye')
            return False
    return True


def main() -> int:
    try:
        flag = os.environ['FLAG']
    except KeyError:
        print('Flag environment variable not set')
        return 1

    if do_game(NUM_ROUNDS):
        print(f'Congratulations! Here is the flag: {flag}')
    else:
        print('You failed! Please try again')

    return 0

if __name__ == '__main__':
    sys.exit(main())

すなわち、「Crackme(正しい入力をリバーシングする問題)が20問与えられるので、各問題10秒以内に解く」タスクになります。手元実行版のhandout/generator_dist.pyではsamples以下のELFファイルが問題として与えられるので、サーバーの本番実行時でも多分ELFが与えられるのだろうと仮定しました。

サンプル1問を手動で解いてみる

とりあえず最初のサンプルの0b7ea2bb6725cf6158f39894e3d8fc0c.elfをIDAで開いてみると、エントリーポイントの関数で色々やっているようでした。IDAの関数Graph表示の時点で、読む気が無くなるものでした:

エントリーポイント関数のグラフ表示

IDAで関数の逆コンパイルはできたので流し見してみましたが、想像通り分岐やループがものすごいことになっていました。Control-Flow Flatteningがされている印象です。"Correct!""Incorrect!"の文字列が見えたのでとりあえずangrライブラリで解けるか試してみましたが、残念ながら解けませんでした。

絶望的な気持ちで逆コンパイル結果を流し見していると、正誤判定を関数ポインター経由で行っているらしいことが分かりました:

if ( (v64(v79) & 1) != 0 )
{
  v66 = 9LL;
  v67 = "Correct!\n";
}
else
{
  v66 = 11LL;
  v67 = "Incorrect!\n";
}

とりあえずデバッガー実行してみようと思いました。ただバイナリがPIEだと大変なので、まずはchecksecコマンドで確認しました:

$ pwn checksec 0b7ea2bb6725cf6158f39894e3d8fc0c.elf
[*] '/mnt/d/Documents/work/ctf/DEF CON CTF 2023 QUALS/live_ctf_6_cheese-and-crackers/handout/samples/0b7ea2bb6725cf6158f39894e3d8fc0c.elf'
    Arch:     amd64-64-little
    RELRO:    No RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x200000)
$

無事、No PIEであると分かりました。次に関数ポインター経由で判定する箇所のアドレスを調べました。IDAの逆コンパイル表示の右クリックメニューから「Synchronize with → IDA View-A, Hex View-1」を選択した状態で、関数ポインター呼び出し箇所にカーソルを合わせた状態でに逆アセンブルウィンドウを見ると、アドレスがすぐ分かって便利です:

.text:000000000020246B                 call    rax

これまでに分かった内容を元に、関数ポインター呼び出し先の処理内容をgdbで追ってみました:

$ gdb -q 0b7ea2bb6725cf6158f39894e3d8fc0c.elf
Reading symbols from 0b7ea2bb6725cf6158f39894e3d8fc0c.elf...
(No debugging symbols found in 0b7ea2bb6725cf6158f39894e3d8fc0c.elf)
gdb-peda$ b *0x000000000020246B
Breakpoint 1 at 0x20246b
gdb-peda$ run
Starting program: /mnt/d/Documents/work/ctf/DEF CON CTF 2023 QUALS/live_ctf_6_cheese-and-crackers/handout/samples/0b7ea2bb6725cf6158f39894e3d8fc0c.elf
TEST_INPUT
Warning: 'set logging off', an alias for the command 'set logging enabled', is deprecated.
Use 'set logging enabled off'.

Warning: 'set logging on', an alias for the command 'set logging enabled', is deprecated.
Use 'set logging enabled on'.
[----------------------------------registers-----------------------------------]
(レジスター表示省略)
[-------------------------------------code-------------------------------------]
   0x20245e:    inc    rcx
   0x202461:    jmp    0x20244b
   0x202463:    lea    rdi,[rsp+0x80]
=> 0x20246b:    call   rax
   0x20246d:    lea    rbx,[rsp+0x90]
   0x202475:    lea    rsi,[rsp+0x20]
   0x20247a:    test   al,0x1
   0x20247c:    je     0x202488
Guessed arguments:
arg[0]: 0x7fffffffe140 --> 0x7ffff7ff8018 ("TEST_INPUT")
[------------------------------------stack-------------------------------------]
(スタック表示省略)
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value

Breakpoint 1, 0x000000000020246b in ?? ()
gdb-peda$ si
[----------------------------------registers-----------------------------------]
(レジスター表示前略)
RDI: 0x7fffffffe140 --> 0x7ffff7ff8018 ("TEST_INPUT")
(レジスター表示後略)
[-------------------------------------code-------------------------------------]
=> 0x7ffff7ff7000:      cmp    QWORD PTR [rdi+0x8],0x20
   0x7ffff7ff7005:      jne    0x7ffff7ff70fc
   0x7ffff7ff700b:      mov    rax,QWORD PTR [rdi]
   0x7ffff7ff700e:      cmp    BYTE PTR [rax],0x30
[------------------------------------stack-------------------------------------]
(スタック表示省略)
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value
0x00007ffff7ff7000 in ?? ()
gdb-peda$ x/100i $rip
=> 0x7ffff7ff7000:      cmp    QWORD PTR [rdi+0x8],0x20
   0x7ffff7ff7005:      jne    0x7ffff7ff70fc
   0x7ffff7ff700b:      mov    rax,QWORD PTR [rdi]
   0x7ffff7ff700e:      cmp    BYTE PTR [rax],0x30
   0x7ffff7ff7011:      jne    0x7ffff7ff70fc
   0x7ffff7ff7017:      cmp    BYTE PTR [rax+0x1],0x62
   0x7ffff7ff701b:      jne    0x7ffff7ff70fc
   0x7ffff7ff7021:      cmp    BYTE PTR [rax+0x2],0x37
   0x7ffff7ff7025:      jne    0x7ffff7ff70fc
   0x7ffff7ff702b:      cmp    BYTE PTR [rax+0x3],0x65
   0x7ffff7ff702f:      jne    0x7ffff7ff70fc
   0x7ffff7ff7035:      cmp    BYTE PTR [rax+0x4],0x61
   0x7ffff7ff7039:      jne    0x7ffff7ff70fc
   0x7ffff7ff703f:      cmp    BYTE PTR [rax+0x5],0x32
   0x7ffff7ff7043:      jne    0x7ffff7ff70fc
   0x7ffff7ff7049:      cmp    BYTE PTR [rax+0x6],0x62
   0x7ffff7ff704d:      jne    0x7ffff7ff70fc
   0x7ffff7ff7053:      cmp    BYTE PTR [rax+0x7],0x62
   0x7ffff7ff7057:      jne    0x7ffff7ff70fc
   0x7ffff7ff705d:      cmp    BYTE PTR [rax+0x8],0x36
   0x7ffff7ff7061:      jne    0x7ffff7ff70fc
   0x7ffff7ff7067:      cmp    BYTE PTR [rax+0x9],0x37
   0x7ffff7ff706b:      jne    0x7ffff7ff70fc
   0x7ffff7ff7071:      cmp    BYTE PTR [rax+0xa],0x32
   0x7ffff7ff7075:      jne    0x7ffff7ff70fc
   0x7ffff7ff707b:      cmp    BYTE PTR [rax+0xb],0x35
   0x7ffff7ff707f:      jne    0x7ffff7ff70fc
   0x7ffff7ff7081:      cmp    BYTE PTR [rax+0xc],0x63
   0x7ffff7ff7085:      jne    0x7ffff7ff70fc
   0x7ffff7ff7087:      cmp    BYTE PTR [rax+0xd],0x66
   0x7ffff7ff708b:      jne    0x7ffff7ff70fc
   0x7ffff7ff708d:      cmp    BYTE PTR [rax+0xe],0x36
   0x7ffff7ff7091:      jne    0x7ffff7ff70fc
   0x7ffff7ff7093:      cmp    BYTE PTR [rax+0xf],0x31
   0x7ffff7ff7097:      jne    0x7ffff7ff70fc
   0x7ffff7ff7099:      cmp    BYTE PTR [rax+0x10],0x35
   0x7ffff7ff709d:      jne    0x7ffff7ff70fc
   0x7ffff7ff709f:      cmp    BYTE PTR [rax+0x11],0x38
   0x7ffff7ff70a3:      jne    0x7ffff7ff70fc
   0x7ffff7ff70a5:      cmp    BYTE PTR [rax+0x12],0x66
   0x7ffff7ff70a9:      jne    0x7ffff7ff70fc
   0x7ffff7ff70ab:      cmp    BYTE PTR [rax+0x13],0x33
   0x7ffff7ff70af:      jne    0x7ffff7ff70fc
   0x7ffff7ff70b1:      cmp    BYTE PTR [rax+0x14],0x39
   0x7ffff7ff70b5:      jne    0x7ffff7ff70fc
   0x7ffff7ff70b7:      cmp    BYTE PTR [rax+0x15],0x38
   0x7ffff7ff70bb:      jne    0x7ffff7ff70fc
   0x7ffff7ff70bd:      cmp    BYTE PTR [rax+0x16],0x39
   0x7ffff7ff70c1:      jne    0x7ffff7ff70fc
   0x7ffff7ff70c3:      cmp    BYTE PTR [rax+0x17],0x34
   0x7ffff7ff70c7:      jne    0x7ffff7ff70fc
   0x7ffff7ff70c9:      cmp    BYTE PTR [rax+0x18],0x65
   0x7ffff7ff70cd:      jne    0x7ffff7ff70fc
   0x7ffff7ff70cf:      cmp    BYTE PTR [rax+0x19],0x33
   0x7ffff7ff70d3:      jne    0x7ffff7ff70fc
   0x7ffff7ff70d5:      cmp    BYTE PTR [rax+0x1a],0x64
   0x7ffff7ff70d9:      jne    0x7ffff7ff70fc
   0x7ffff7ff70db:      cmp    BYTE PTR [rax+0x1b],0x38
   0x7ffff7ff70df:      jne    0x7ffff7ff70fc
   0x7ffff7ff70e1:      cmp    BYTE PTR [rax+0x1c],0x66
   0x7ffff7ff70e5:      jne    0x7ffff7ff70fc
   0x7ffff7ff70e7:      cmp    BYTE PTR [rax+0x1d],0x63
   0x7ffff7ff70eb:      jne    0x7ffff7ff70fc
   0x7ffff7ff70ed:      cmp    BYTE PTR [rax+0x1e],0x30
   0x7ffff7ff70f1:      jne    0x7ffff7ff70fc
   0x7ffff7ff70f3:      cmp    BYTE PTR [rax+0x1f],0x63
   0x7ffff7ff70f7:      sete   al
   0x7ffff7ff70fa:      jmp    0x7ffff7ff70fe
   0x7ffff7ff70fc:      xor    eax,eax
   0x7ffff7ff70fe:      ret
   0x7ffff7ff70ff:      add    BYTE PTR [rax],al
(中略)
   0x7ffff7ff7139:      add    BYTE PTR [rax],al
gdb-peda$

関数ポインター経由で呼び出している処理の逆アセンブル結果を見ると、入力の文字列長らしいものを検証した後に、後は素直に1文字単位で検証しているようです。逆アセンブル結果を加工したものを入力として与えてみました:

$ ./0b7ea2bb6725cf6158f39894e3d8fc0c.elf
0b7ea2bb6725cf6158f39894e3d8fc0c
Correct!
$

無事、手動でサンプルの1つを解けました。後はこれを自動化する必要があります。

自動化を考える

手動で解く際に使用した以下の要素の自動化を考えました:

  • 関数ポインター経由での呼び出しをしているアドレスの特定
    • 先程手動で試したときはcall rax命令でした。他にcall レジスタの呼び出しがあると判定が面倒そうでしたが、幸いその1箇所だけのようです。そのため問題のELFを逆アセンブルすれば、アドレスを特定できそうです。
    • 目的からするとエントリーポイント関数だけを逆アセンブルできれば事足りますがその方法を思いつかなかったので、objdump -dコマンドでELF全体を逆アセンブルすることにしました。
    • rax以外のレジスタを使う問題があると大変そうです。しかしひとまずraxレジスタを使うと想定して、他のレジスタが使われることが分かったら、その後に考えようと思いました。(結果的に、raxレジスタだけ使うようでした。)
  • デバッガーを使った、関数ポインター経由での呼び出し場所でのブレークポイント設置、ステップイン、逆アセンブル表示
    • 手動で試した場合と同様に、gdb経由で実行させるのが簡単そうです。なお、手動実行時はpedaを導入している環境でしたが、pedaの機能は使わないのでバニラのgdbで十分です。
    • gdbの自動操作には、pwntoolsのprocess関数を使えそうです。
      • 最初はgdb.debug関数を使おうとしていたのですが、どうやらその関数はgdbの手動操作が前提のようでした。プログラム側からgdbへ与えられる命令はgdb.debug関数の引数だけで、その後のtube操作はgdb本体ではなくデバッグ中プログラムへの入出力となるようです。
    • 逆アセンブル結果で使われるレジスタが、サンプルで見た問題とは違うレジスタになっていると大変そうです。しかしひとまずサンプルと同じレジスタと決め打ちして、他のレジスタが使われることが分かったらその後に考えようと思いました。(結果的に、サンプルと同じレジスタが使われているようでした。)

組み合わせる

試行錯誤を積み重ねつつ、以下のソルバーを書きました:

#!/usr/bin/env python3

import os
import pwn
import subprocess
import re
import time
import tempfile
import base64
import glob

pwn.context.log_level = "CRITICAL" # supress connection log

def get_call_rax_address(elf_path):
    # 手元環境では 「20246b:       ff d0                   call   *%rax」と「call」なのに
    # Docker環境では「20246b:       ff d0                    callq  *%rax」と「callq」でした……
    out = subprocess.run(["objdump", "-d", elf_path], capture_output=True, text=True).stdout
    # print(out)
    match = re.search(r"(\w+):\s+ff \w{2}\s+callq?\s+\*%rax", out)
    assert match
    return int(match.group(1), 16)

# return input for "Correct"
def solve_one_crackme(elf_path) -> bytes:
    addr_call_rax = get_call_rax_address(elf_path)
    # print(f"{hex(addr_call_rax) = }")
    with pwn.process(["gdb", "--nx", elf_path]) as io:
        PROMPT = b"(gdb)"
        io.sendlineafter(PROMPT, b"set disable-randomization off") # Avoid "warning: Error disabling address space randomization: Operation not permitted"
        io.sendlineafter(PROMPT, b"set style enabled off") # disable coloring
        io.sendlineafter(PROMPT, b"set disassembly-flavor intel")
        io.sendlineafter(PROMPT, f"break *{hex(addr_call_rax)}".encode())
        io.sendlineafter(PROMPT, b"run")
        io.recvuntil(b"Starting program")
        time.sleep(0.5)
        io.sendline(b"DUMMY_INPUT")
        io.sendlineafter(PROMPT, b"si")
        io.sendlineafter(PROMPT, b"x/100i $rip")
        for _ in range(20):
            io.sendline(b"") # empty line will be nessesary for processing "Type <RET> for more, q to quit, c to continue without paging" line
        io.sendline(b"quit") # "exit" command does not exist!

        result = bytearray(64)
        for line in io.recvall().decode().split("\n"):
            # index 0
            # eg: "0x7ffff7ff700e:      cmp    BYTE PTR [rax],0x33"
            m = re.search(r"\w+:\s+cmp\s+BYTE PTR \[rax\],(\w+)", line)
            if m:
                result[0] = int(m.group(1), 0)
            else:
                # other index
                # eg: "0x7ffff7ff70f3:      cmp    BYTE PTR [rax+0x1f],0x31"
                m = re.search(r"\w+:\s+cmp\s+BYTE PTR \[rax\+(\w+)\],(\w+)", line)
                if m:
                    index = int(m.group(1), 0)
                    value = int(m.group(2), 0)
                    result[index] = value
    return bytes(result.rstrip(b"\x00"))

def solve_challenge(io):
    for i in range(20):
        io.recvuntil(b"Crackme: ")
        elf = base64.b64decode(io.recvline().decode())

        # if I use tempfile.NamedTemporaryFile, then I will got "text file busy" error...
        filename = f"challange_{i:02}.elf"
        with open(filename, "wb") as f:
            f.write(elf)
        subprocess.run(["chmod", "777", filename], capture_output=True, text=True)
        cracked_flag = solve_one_crackme(filename)
        io.sendlineafter(b"Password:", cracked_flag)
    print(io.recvall().decode())


HOST = os.environ.get('HOST', 'localhost')
PORT = 31337

with pwn.remote(HOST, int(PORT)) as io: solve_challenge(io)
# with pwn.process(["python3", "../handout/server.py"], env={"FLAG":"flag{test}", "LOCAL":"1"}) as io: solve_challenge(io) # this doesn't work!

def test_local_samples():
    for f in glob.glob("../handout/samples/*.elf"):
        print(f)
        cracked_flag = solve_one_crackme(f)
        print(cracked_flag.decode())

その他、配布されている状態のDockerfileテンプレートに、objdumpコマンドやgccコマンドをインストールするコマンドを追加しました:

FROM livectf/livectf:quals-exploit

RUN apt-get update
RUN apt-get -y install gdb binutils
COPY solve.py /solve.py

WORKDIR /
CMD ["python3", "solve.py"]

提出手順に従って、上記2ファイルをtarでまとめたファイルを提出すると正解できました。

以下、本問題を解く上でハマったところです:

  • ソルバーのコメントにも入れていますが、objdump -d出力が、手元環境だとcall raxですが、Dockerコンテナ環境ではcallq raxqが増えていました。パースする正規表現を変えて対処しました。
  • Dockerコンテナビルド中のRUN apt-get -y install gdbが失敗していました。先にRUN apt-get updateを先に実行してやると成功しました。そういうこともあるんですね……
  • Dockerコンテナ中でgdbコマンド経由で実行すると、Error disabling address space randomizationエラーが起こりました。メッセージでググるとc - warning: Error disabling address space randomization: Operation not permitted - Stack Overflowを見つけて、対応策として提示されていたset disable-randomization offをgdbで実行しておくと対処できました。
  • 手元環境のgdbではexitコマンドが存在していましたが、Dockerコンテナ中のgdbでは存在しませんでした。そのためgdbプロセスが終了せず、io.recvall()が完了せずに困っていました。Dockerコンテナ中のgdbにもquitコマンドは存在したので、それを使って対処できました。

感想

  • 全体的にジャンル表記がなく、ジャンルの理解から始まる印象でした。また、LiveCTF以外の問題は、配布ファイルがなかったり、問題説明を読んでも何をすればいいのかよく分からなかったりしました。
    • Pwnらしい問題や、Revらしい問題はまだ分かりやすかったです。Webらしい問題もありました。一方でCrypto問題あったんでしょうか、ぱっと見では無さそうでした。
  • その意味では、LiveCTF問題は配布ファイルがあることに加えて、特記事項があればそれが目標、なければ./submitter実行が目標と分かりやすかったように思います。
  • 48時間はものすごく長いです。また、LiveCTFを全問題頑張ろうと思うと24時間連続稼働が必要だったはずです。上位を狙うチームはおそらくものすごく過酷だったんじゃないかと思います……。私は真っ当に寝たり、用事で外出したりしていました。
  • LiveCTF問題で配布されているtest-solutions.shを使用して、サーバーへのアップロードと、サーバー側プログラムの動作確認をできるのが良かったです。Dockerfileを編集するのが初めてだったので、試行錯誤しやすくて助かりました。