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 Overflowやglibc/random_r.c at master · lattera/glibc · GitHubを見つけました。それらによると、今回のrandom_r
関数ではTYPE_3
というモードを使っているらしいことが分かりました。
「なんとかしてTYPE_3
のrand
実装の次の値を予測できる方法は無いものか」と色々ググっていると、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 rax
とq
が増えていました。パースする正規表現を変えて対処しました。 - 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を編集するのが初めてだったので、試行錯誤しやすくて助かりました。