Ricerca CTF 2023に参加し、reversing問題に取り組みました。そのwrite-up記事です。
2023/04/23(日) 23:40頃に、「tic tac toe?」問題解説の加筆修正と、感想の追加をしました。
2023/04/27(木) 01:25頃に、「ignition」問題のフラグが一部正しくでなかった現象について加筆しました。
2023/07/09(日) 16:23頃に、Writeup賞受賞について加筆しました。
コンテスト概要
2023/04/22(土) 10:00 +09:00 - 2023/04/22(土) 22:00 +09:00 の12時間の開催でした。他ルールはRulesページから引用します:
Ricerca CTF 2023 について Ricerca CTF 2023 はオンラインにて開催されます。 開催期間は 2023/04/22 10:00 - 2023/04/22 22:00 (UTC+9) の 12 時間です。 公式ハッシュタグは #RicercaCTF です。 運営からの連絡は Discord にて行います。 (URL略) 国籍、年齢、性別などに関係なく、どなたでも参加いただけます。 チーム人数に上限はありません。複数人で参加する場合はチーム機能を利用してください。 競技について フラグは特に指定のない場合、RicSec{[\x20-\x7e]+} という形式をとります。 フラグは何度でも提出することができます。ただし、短時間に多くの提出が認められた場合、一定時間提出が禁止される可能性があります。 誤ったフラグを入力することによる減点はありません。 問題の点数は解かれた人数に応じて減少します。点数の減少はすでに正解したチームにも等しく適用されます。 問題サーバへ大量にアクセスしなければフラグを入手できない問題や、有償のツールまたは環境が必要な問題はありません。 賞金について (略) Writeup について CTF 競技期間終了後、 Writeup の公開を推奨します。 Writeup 賞 競技終了後 1 週間以内にハッシュタグ #RicercaCTF をつけてツイートされた Writeup の中で、運営からの評価が高かった数本の Writeup の著者にささやかな賞品を贈呈いたします。 受賞連絡は Twitter のダイレクトメッセージ機能を用いて行います。そのため、応募者の方はツイートしたアカウントでのダイレクトメッセージの受取許可設定をお願いいたします。なお、連絡後 1 日以内にお返事がいただけない場合やダイレクトメッセージの受取が許可されていない場合は、受賞が取り消される場合があります。 扱う問題数、記述媒体、著者の属性などに対する運営による評価の差異はありません。例えば正答数が 1 問である Writeup でも受賞対象となります。また、複数人で記述された Writeup においては代表者 1 名のみに発送いたします。なお、Writeup 賞の発送先は日本国内の住所のみに限定させて頂きます。予めご了承ください。 みなさんのユニークな Writeup をお待ちしています。 禁止行為 (略)
Writeupの公開が推奨されているのはありがたいです。そのため開催終了直後の今、意気揚々と書き始めています。
結果
Reversingジャンルにひたすら取り組んだ結果、4問全問解けました!
環境
WindowsのWSL2(Ubuntu 22.04)を使って取り組みました。
Windows
c:\>ver Microsoft Windows [Version 10.0.19045.2846] c:\>wsl -l -v NAME STATE VERSION Ubuntu Stopped 2 kali-linux Stopped 2 docker-desktop-data Stopped 2 docker-desktop Running 2 * Ubuntu-22.04 Running 2 c:\>
他ソフト
- IDA Free Version 8.2.221215 Windows x64 (64-bit address size) (なお、Free版IDA version 8.2からはx86バイナリもクラウドベースの逆コンパイルができます。version 7頃から引き続き、x64バイナリも同様に逆コンパイルができます。)
- Ghidra Version 9.2.2 (最新バージョンはversion 10.2.3です。今回は問題中でバージョンのヒントがあったので古いバージョンを使いました。)
- CFF Explorer VII © 2012 Daniel Pistelli
- x64dbgx Version Oct 2020, 23:08:03
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 IPython | grep Version Version: 7.31.1 $ python3 -m pip show pwntools | grep Version Version: 4.9.0 $ 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 $ docker-compose --version Docker Compose version v2.17.2 $
解けたreversingジャンル問題
各問題の説明文は、日本語版と英語版が提供されていました。本記事では日本語版の問題文を掲載します。
[reversing, warmup] crackme (134 solves, 88 points)
パスワードをクラックできますか。
配布ファイルとして、crackme
がありました:
$ file * crackme: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=e109ac8573ee031e7432c0d27a973fb37e492c80, for GNU/Linux 3.2.0, stripped $
crackme
をIDAで開いて逆コンパイルすると、以下の内容でした:
__int64 __fastcall main(int a1, char **a2, char **a3) { unsigned int dwReturnCode; // r12d char *v4; // rsi const char *v5; // rdi void (*v6)(void); // rdx char _0[96]; // [rsp+0h] [rbp+0h] BYREF int anonymous0; // [rsp+60h] [rbp+60h] unsigned __int64 vars68; // [rsp+68h] [rbp+68h] dwReturnCode = 1; vars68 = __readfsqword(0x28u); __printf_chk(1LL, "Password: ", a3); memset(_0, 0, sizeof(_0)); v4 = _0; anonymous0 = 0; v5 = "%99s"; if ( (unsigned int)__isoc99_scanf("%99s", _0) == 1 ) { v4 = "N1pp0n-Ich!_s3cuR3_p45$w0rD"; dwReturnCode = 1; if ( !strcmp(_0, "N1pp0n-Ich!_s3cuR3_p45$w0rD") ) { dwReturnCode = 0; puts("[+] Authenticated"); v5 = _0; sub_1290(_0); } else { v5 = "[-] Permission denied"; puts("[-] Permission denied"); } } if ( __readfsqword(0x28u) != vars68 ) start((__int64)v5, (__int64)v4, v6); return dwReturnCode; }
scanf
で読んだ内容をstrcmp
で固定文字列と比較しているようです。実行して試してみました:
$ ./crackme Password: N1pp0n-Ich!_s3cuR3_p45$w0rD [+] Authenticated The flag is "RicSec{U_R_h1y0k0_cr4ck3r!}" $
フラグを入手できました: RicSec{U_R_h1y0k0_cr4ck3r!}
[reversing] ignition (6 solves, 341 points)
3... 2... 1... ignition. ヒント: Ghidra v9.2.2 を使用してください
配布ファイルとして、以下のものがありました:
$ file * Dockerfile: ASCII text challenge.jsc: data check.sh: POSIX shell script, ASCII text executable $ cat Dockerfile FROM node:8 RUN npm i -g bytenode COPY challenge.jsc / ENTRYPOINT ["bytenode", "/challenge.jsc"] $ cat check.sh #!/bin/sh docker build -t ignition . > /dev/null 2>&1 docker run --rm ignition $@ $
challenge.jsc
の内容をヘキサエディタで眺めてみましたが、よくわからない形式でした。JSC形式についてググっていると、bytenode/bytenode: A minimalist bytecode compiler for Node.jsを見つけました。Node.js向けのコンパイラーでコンパイルした結果がJSC形式とのことです。
check.sh
を見るにdocker経由で実行できそうだったので試しました。数分でイメージをビルドできたので実行してみました:
$ docker run --rm ignition Usage: check.sh <flag> $ ./check.sh test Wrong $
コマンドライン引数がフラグかどうかを判定してくれるプログラムのようです。それではとJSC形式を逆コンパイルしようと思いました。最初は問題文を愚直に解釈してGhidra v9.2.2へJSC形式を与えてみても「Raw Binary」表記のみで、読み込めませんでした。困ったのでググってみると、PositiveTechnologies/ghidra_nodejs: GHIDRA plugin to parse, disassemble and decompile NodeJS Bytenode (JSC) binariesを見つけました。Ghidraプラグインです!googleinterns/ghidra-nsis-extension: Ghidra extension to disassemble NSIS installersの手順でExtensionとして導入しました(こちらの説明ではPluginではありませんでした、用語が謎です)。そうすると、今度は「Jsc (Bytenode) Loader」を使用して、Ghidraで無事JSCファイルを読み込めました。
一通り眺めてみると、3つの関数main
、checkFlag
、F
がありました。main
関数は実質checkFlag
関数呼び出しと出力のみを行う関数でした。F
関数の逆コンパイル結果は以下の内容でした:
int F(int n) { code *pcVar1; int iVar2; int iVar3; StackCheck(); iVar2 = True; if (n != 0) { iVar2 = False; } if (iVar2 != False) { return 0; } iVar2 = True; if (n != 1) { iVar2 = False; } if (iVar2 == False) { pcVar1 = (code *)F; if (pcVar1 == TheHole) { pcVar1 = (code *)(*(code *)ThrowReferenceError)("F",_context); } iVar2 = (*pcVar1)(n + -1); pcVar1 = (code *)F; if (pcVar1 == TheHole) { pcVar1 = (code *)(*(code *)ThrowReferenceError)("F",_context); } iVar3 = (*pcVar1)(n + -2); return iVar2 + iVar3; } return 1; }
すなわちフィボナッチ数列を求める関数でした。なお、TheHole
とはnode.jsの用語で未割り当てを表す値のようです。また、StackCheck()
は関数呼び出しではなく、特殊なオペコードのようでした。
そういうわけで、残るは本体のcheckFlag
関数です:
int checkFlag(char *input,undefined4 this) { undefined4 someArrayLiteral; undefined4 uVar1; int iVar2; code *pcVar3; int iVar4; uint uVar5; uint fibonacciCounter; uint uVar6; int dwCounter; int encoded; uint charCode; _context = CreateFunctionContext(_context,_closure,1); *(undefined4 *)F = TheHole; StackCheck(); someArrayLiteral = CreateArrayLiteral(_context,_closure,0,0,0x25); uVar1 = CreateClosure(_context,F,1,2); *(undefined4 *)F = uVar1; iVar2 = LdaNamedProperty(input,length); /* フラグ長 */ dwCounter = True; if (iVar2 != 0x24) { dwCounter = False; } if (dwCounter != True) { return False; } pcVar3 = (code *)LdaNamedProperty(input,"slice"); iVar2 = (*pcVar3)(7,0); iVar4 = GetConstant((undefined8)"RicSec{"); dwCounter = True; if (iVar2 != iVar4) { dwCounter = False; } if (dwCounter != False) { pcVar3 = (code *)LdaNamedProperty(iVar2,"slice"); uVar5 = (*pcVar3)(0xffffffff,this); charCode = GetConstant((undefined8)"}"); dwCounter = True; if (uVar5 != charCode) { dwCounter = False; } if (dwCounter == True) { dwCounter = 0; while( true ) { iVar2 = True; if (0x1b < dwCounter) { iVar2 = False; } if (iVar2 == False) { /* ここに来て欲しい */ return True; } StackCheck(); pcVar3 = (code *)LdaNamedProperty(uVar5,"charCodeAt"); uVar5 = (*pcVar3)(dwCounter + 7); charCode = uVar5; fibonacciCounter = (*(code *)F)(dwCounter); uVar6 = LdaKeyedProperty(_context,someArrayLiteral,dwCounter); iVar2 = True; if ((charCode ^ fibonacciCounter & 0xff) != uVar6) { iVar2 = False; } /* breakはしてほしくない */ if (iVar2 != True) break; dwCounter = dwCounter + 1; CheckOSRLevel(0); } return False; } } return False; }
謎のthis
引数があったり、ローカル変数の使い回しが激しかったり、slice
関数やcharCodeAt
関数の呼び出し方が奇妙なのでエスパーしたりしましたが、頑張って読むと以下の処理を行っていることが分かりました:
- 入力文字列の長さが0x24(=36)文字であることを確認
- 入力文字列が
RicSec{
であることを確認 - 入力文字列の最後が
}
であることを確認 - 入力文字列の7文字目から0x1b(=27)文字すべてについて、以下の式が成り立つことを確認:
(charCodeAt(index) ^ F(index)) & 0xFF == 何らかの配列[index]
ここまで分かりましたが、someArrayLiteral = CreateArrayLiteral(_context,_closure,0,0,0x25);
で構築している配列の要素が分かっていませんでした。逆アセンブル表示を一通り眺めましたがそれでも見当たりません。「デバッグ実行ができれば配列要素も分かるかも」と思ってデバッグ実行方法をググったりもしましたが分からないまま終わりました。しばらく悩んで確認できる範囲をひたすら眺めていたときのことです:
.arrs
セクションに配列を発見しました!要素数が28であり、checkFlag
で使用する配列長と一致することから、この配列がCreateArrayLiteral
で読み込まれているものだと考えました。これらの発想でフラグを計算するソルバーを書きました:
#!/usr/bin/env python3 import functools @functools.cache # 愚直だと指数オーダーなので計算量削減 def Fibonacci(n): if n == 0: return 0 if n == 1: return 1 return Fibonacci(n-2) + Fibonacci(n-1) arr = [0x31,0x66,0x6F,0x33,0x77,0x34,0x38,0x63,0x4A,0x40,0x4E,0x2D,0xF5,0x8A,0x49,0x60,0xBE,0x62,0x29,0x26,0x32,0xB1,0x1F,0xAE,0x52,0x20,0x52,0x2A,] print("RicSec{", end="") for i in range(0x1c): c = (arr[i] ^ Fibonacci(i)) & 0xFF print(chr(c), end="") print("}")
実行しました:
$ ./solve.py RicSec{1gn1t10n_bytec0^Be_1s_s0_r1ch} $ ./solve.py | xxd 00000000: 5269 6353 6563 7b31 676e 3174 3130 6e5f RicSec{1gn1t10n_ 00000010: 6279 7465 6330 0265 5f31 735f 7330 5f72 bytec0.e_1s_s0_r 00000020: 3163 687d 0a 1ch}. $
いい感じに文字列が見えていますが、bytec0?e
の後ろから2文字目だけ0x02のバイトになっています。「多分、Dかdのどちらかでしょう」と想像してRicSec{1gn1t10n_bytec0de_1s_s0_r1ch}
を提出してみると正解でした。
なぜ1バイトだけ変になったのかは分かっていません。
(2023/04/27(木) 01:25頃追記)Ricerca CTF 2023 Writeup - rex-gsの記事で、同じ現象に悩んでおられて、かつ原因に言及がありました。Item15
だけ7桁の16進数で表記されていたことが原因でした!私は.arrs
セクションの内容をテキストエディタで矩形編集してPython用コードの配列を作ったので、Item15
の要素を本来の0x06
ではなく、誤って0x60
としてしまったのが原因でした。
[reversing] tic tac toe? (6 solves, 341 points)
古のインターネットからゲームをダウンロードしましたが、どうやらバグっているようです......
配布ファイルとして、challenge
がありました:
$ file * challenge: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=4d077ffa570ecba8a5a440696a02cda3517150e1, for GNU/Linux 3.2.0, stripped $
IDAで開いて逆コンパイルして読み進めました。構造体を使っているようなのでcalloc
からサイズを推測してメンバーを割り当てたり、適宜リネームを施したものがこちらです:
00000000 SomeContext struc ; (sizeof=0x28, mappedto_13) 00000000 dwFieldSize9 dd 9 dup(?) ; 末尾2ビットで判定 00000024 dwCurrentTurnBits dd ? ; XREF: main+70/r 00000024 ; ShowGameBoard+25/o ; 末尾2ビットのみが意味を持つらしい。 00000024 ; xxxxxx01: Player1 00000024 ; xxxxxx10: Player2 00000028 SomeContext ends
int __fastcall __noreturn main() { int dwNextTurnBits; // ebx SomeContext *pSomeContext; // rax SomeContext *pSomeContext2; // r15 int dwCurrentTurn; // eax const char *pstrCurrentPlayer; // rdx unsigned int dwInputedColumn; // eax unsigned int dwInputedRow; // edx int valueCellUpperLeft; // r14d int v8; // esi int valueCellUpperMiddle; // r13d int v10; // eax int v11; // ecx int v12; // ecx int v13; // edi char v14; // r14^1 __int64 dwCurrentTurnBits; // rax int v16; // eax int v17; // [rsp+0h] [rbp-48h] BYREF char strInputSize3[3]; // [rsp+5h] [rbp-43h] BYREF unsigned __int64 v19; // [rsp+8h] [rbp-40h] v19 = __readfsqword(0x28u); setvbuf(stdout, 0LL, 2, 0LL); pSomeContext = (SomeContext *)calloc(1uLL, sizeof(SomeContext)); pSomeContext->dwCurrentTurnBits = 1; pSomeContext2 = pSomeContext; InitializeGlobalByteArrayWithFixedValues(); while ( 1 ) { ShowGameBoard(pSomeContext2); dwCurrentTurn = pSomeContext2->dwCurrentTurnBits; pstrCurrentPlayer = "Player 1"; if ( (dwCurrentTurn & 1) == 0 ) { pstrCurrentPlayer = ""; if ( (dwCurrentTurn & 2) != 0 ) pstrCurrentPlayer = "Player 2"; } __printf_chk(1LL, "%s's turn.\n", pstrCurrentPlayer); while ( 1 ) { __printf_chk(1LL, "Enter position (e.g. a1): "); __isoc99_scanf("%2s", strInputSize3); dwInputedColumn = strInputSize3[0] - 'a'; if ( dwInputedColumn <= 2 ) { dwInputedRow = strInputSize3[1] - '1'; if ( dwInputedRow <= 2 ) break; } puts("Invalid position."); } pSomeContext2->dwFieldSize9[3 * dwInputedColumn + dwInputedRow] += pSomeContext2->dwCurrentTurnBits;// +=なので3にできそう→無限に増やせる!! valueCellUpperLeft = pSomeContext2->dwFieldSize9[0];// 以降勝敗判定 // [0]: 左上 // [1]; 左中 // [2]: 左下 // [3]: 上中 // [4]: 中央 // [5]: 下中 // [6]: 右上 // [7]: 右中 // [8]: 右下 v8 = pSomeContext2->dwFieldSize9[0] & 3; if ( v8 ) { if ( (((unsigned __int8)valueCellUpperLeft ^ LOBYTE(pSomeContext2->dwFieldSize9[4])) & 3) == 0 && (((unsigned __int8)valueCellUpperLeft ^ LOBYTE(pSomeContext2->dwFieldSize9[2])) & 3) == 0 ) { break; // 0-2-4、012じゃないのがバグっぽい } } valueCellUpperMiddle = pSomeContext2->dwFieldSize9[3]; if ( (valueCellUpperMiddle & 3) != 0 && (((unsigned __int8)valueCellUpperMiddle ^ LOBYTE(pSomeContext2->dwFieldSize9[4])) & 3) == 0 && (((unsigned __int8)valueCellUpperMiddle ^ LOBYTE(pSomeContext2->dwFieldSize9[5])) & 3) == 0 ) { break; // 3-4-5、縦中央 } // 他の6方向の判定も同様に行っており、かつフラグに関係ないので省略します if ( !fork() ) exit(valueCellUpperMiddle + valueCellUpperLeft); wait((__WAIT_STATUS)&v17); v14 = BYTE1(v17); if ( !fork() ) exit(v14 == 19); wait((__WAIT_STATUS)&v17); if ( BYTE1(v17) ) // 19になってほしい! { LOBYTE(v16) = IsCorrectBoardState(pSomeContext2); if ( v16 ) DecryptAndShowFlag(pSomeContext2); } dwCurrentTurnBits = (unsigned int)pSomeContext2->dwCurrentTurnBits; if ( (unsigned int)dwCurrentTurnBits <= 2 ) dwNextTurnBits = g_nextTurnTable[dwCurrentTurnBits]; pSomeContext2->dwCurrentTurnBits = dwNextTurnBits; } ShowWinPlayerAndExit(pSomeContext2); }
マルバツゲームのようですが、縦横斜めの8方向のうち、2方向の判定がバグっているように見えました。また、勝敗判定と関係ないところでfork
関数とwait
関数を使った謎の判定があり、特定の条件を満たした場合にフラグを表示するようでした。
wait
関数のポインター引数で受け取った4バイト値のうち、BYTES1
(=下から8~16ビット目を1バイト値として解釈する)を使っている理由が分からなかったのでドキュメント類を探しました:
- ポインター引数経由で受け取った値は、
WEXITSTATUS
等のマクロを利用して判定に使う WEXITSTATUS
という、子プロセスのexit codeを取得するマクロの実装がBYTES1
そのもの(参考: src/wait.h at master · openbsd/src)
これらを総合すると、「fork
先の子プロセスのexit
関数の引数を受け取っている」ことが分かりました。知ってようやく意味が分かりましたし、知っていても読解が大変なやつです。また、上に貼ったコードでIsCorrectBoardState
としている関数では、fork
, exit
, wait
を多用して、3*3の盤面が特定の状態であるかを判定していました:
// forkとwaitで盤面状態が特定の状態か検証しています bool __fastcall IsCorrectBoardState(SomeContext *pContext) { int c6; // r12d int c3_1; // ebp BYTE v3; // r12^1 _BOOL8 v4; // rax int dwDiv_rhv; // r13d int dwDiv_lhv; // r12d BYTE valueDivResult; // r12^1 int dwPlus_lhv; // r12d int dwPlus_rhv; // r13d BYTE dwWaitResult; // r12^1 int dwMul_rhv; // r12d int dwMul_lhv; // r13d BYTE v13; // r12^1 __int16 dwWaitResult_1; // r12 int dwSub_rhv; // r13d int dwSub_lhv; // r12d BYTE v17; // r12^1 BYTE v18; // r12^1 BYTE v19; // r12^1 BYTE v20; // r12^1 BYTE v21; // r12^1 BYTE v22; // r12^1 int c3; // r12d int c8; // ebx BYTE valueMustBe6; // bh BYTE waitStatus[4]; // [rsp+4h] [rbp-34h] BYREF unsigned __int64 qwCanary; // [rsp+8h] [rbp-30h] c6 = pContext->dwFieldSize9[6]; c3_1 = pContext->dwFieldSize9[3]; qwCanary = __readfsqword(0x28u); if ( !fork() ) exit(c3_1 - c6); wait((__WAIT_STATUS)waitStatus); v3 = waitStatus[1]; if ( !fork() ) exit(v3 == 247); wait((__WAIT_STATUS)waitStatus); if ( !waitStatus[1] ) goto labelReturn0; dwDiv_rhv = pContext->dwFieldSize9[1]; dwDiv_lhv = pContext->dwFieldSize9[6]; if ( !fork() ) goto Exit_Div; wait((__WAIT_STATUS)waitStatus); valueDivResult = waitStatus[1]; if ( !fork() ) goto labelExit_DivResult_Equals_4; wait((__WAIT_STATUS)waitStatus); if ( !waitStatus[1] ) goto labelReturn0; dwPlus_lhv = pContext->dwFieldSize9[4]; dwPlus_rhv = pContext->dwFieldSize9[1]; if ( !fork() ) goto labelExit_Plus; wait((__WAIT_STATUS)waitStatus); dwWaitResult = waitStatus[1]; if ( !fork() ) labelExit_WaitResult_MustBe_9: exit(dwWaitResult == 9); wait((__WAIT_STATUS)waitStatus); if ( !waitStatus[1] ) goto labelReturn0; dwMul_rhv = pContext->dwFieldSize9[7]; dwMul_lhv = pContext->dwFieldSize9[4]; if ( !fork() ) goto labelExit_Mul; wait((__WAIT_STATUS)waitStatus); v13 = waitStatus[1]; if ( !fork() ) exit(v13 == 54); wait((__WAIT_STATUS)waitStatus); if ( !waitStatus[1] ) goto labelReturn0; dwMul_rhv = pContext->dwFieldSize9[2]; dwMul_lhv = pContext->dwFieldSize9[7]; if ( !fork() ) labelExit_Mul: exit(dwMul_lhv * dwMul_rhv); wait((__WAIT_STATUS)waitStatus); dwWaitResult_1 = *(_WORD *)waitStatus; if ( !fork() ) goto labelExit_MulResult_MustBe_0; wait((__WAIT_STATUS)waitStatus); if ( !waitStatus[1] ) { labelReturn0: LOBYTE(v4) = 0; return v4; } dwSub_rhv = pContext->dwFieldSize9[5]; dwSub_lhv = pContext->dwFieldSize9[2]; if ( !fork() ) goto labelExit_Sub; wait((__WAIT_STATUS)waitStatus); v17 = waitStatus[1]; if ( !fork() ) exit(v17 == 0xF2); wait((__WAIT_STATUS)waitStatus); if ( !waitStatus[1] ) goto labelReturn0; dwSub_rhv = pContext->dwFieldSize9[8]; dwSub_lhv = pContext->dwFieldSize9[5]; if ( !fork() ) goto labelExit_Sub; wait((__WAIT_STATUS)waitStatus); v18 = waitStatus[1]; if ( !fork() ) exit(v18 == 5); wait((__WAIT_STATUS)waitStatus); if ( !waitStatus[1] ) goto labelReturn0; dwDiv_rhv = pContext->dwFieldSize9[0]; dwDiv_lhv = pContext->dwFieldSize9[8]; if ( !fork() ) goto Exit_Div; wait((__WAIT_STATUS)waitStatus); dwWaitResult_1 = *(_WORD *)waitStatus; if ( !fork() ) goto labelExit_MulResult_MustBe_0; wait((__WAIT_STATUS)waitStatus); if ( !waitStatus[1] ) goto labelReturn0; dwSub_rhv = pContext->dwFieldSize9[6]; dwSub_lhv = pContext->dwFieldSize9[0]; if ( !fork() ) labelExit_Sub: exit(dwSub_lhv - dwSub_rhv); wait((__WAIT_STATUS)waitStatus); valueDivResult = waitStatus[1]; if ( !fork() ) labelExit_DivResult_Equals_4: exit(valueDivResult == 4); wait((__WAIT_STATUS)waitStatus); if ( !waitStatus[1] ) goto labelReturn0; dwMul_rhv = pContext->dwFieldSize9[1]; dwMul_lhv = pContext->dwFieldSize9[3]; if ( !fork() ) goto labelExit_Mul; wait((__WAIT_STATUS)waitStatus); dwWaitResult = waitStatus[1]; if ( !fork() ) goto labelExit_WaitResult_MustBe_9; wait((__WAIT_STATUS)waitStatus); if ( !waitStatus[1] ) goto labelReturn0; dwPlus_lhv = pContext->dwFieldSize9[4]; dwPlus_rhv = pContext->dwFieldSize9[6]; if ( !fork() ) goto labelExit_Plus; wait((__WAIT_STATUS)waitStatus); v19 = waitStatus[1]; if ( !fork() ) exit(v19 == 18); wait((__WAIT_STATUS)waitStatus); if ( !waitStatus[1] ) goto labelReturn0; dwPlus_lhv = pContext->dwFieldSize9[7]; dwPlus_rhv = pContext->dwFieldSize9[1]; if ( !fork() ) labelExit_Plus: exit(dwPlus_lhv + dwPlus_rhv); wait((__WAIT_STATUS)waitStatus); v20 = waitStatus[1]; if ( !fork() ) exit(v20 == 12); wait((__WAIT_STATUS)waitStatus); if ( !waitStatus[1] ) goto labelReturn0; dwSub_rhv = pContext->dwFieldSize9[2]; dwSub_lhv = pContext->dwFieldSize9[4]; if ( !fork() ) goto labelExit_Sub; wait((__WAIT_STATUS)waitStatus); v21 = waitStatus[1]; if ( !fork() ) exit(v21 == 6); wait((__WAIT_STATUS)waitStatus); if ( !waitStatus[1] ) goto labelReturn0; dwDiv_rhv = pContext->dwFieldSize9[5]; dwDiv_lhv = pContext->dwFieldSize9[7]; if ( !fork() ) Exit_Div: exit(dwDiv_lhv / dwDiv_rhv); wait((__WAIT_STATUS)waitStatus); dwWaitResult_1 = *(_WORD *)waitStatus; if ( !fork() ) goto labelExit_MulResult_MustBe_0; wait((__WAIT_STATUS)waitStatus); if ( !waitStatus[1] ) goto labelReturn0; dwMul_rhv = pContext->dwFieldSize9[8]; dwMul_lhv = pContext->dwFieldSize9[2]; if ( !fork() ) goto labelExit_Mul; wait((__WAIT_STATUS)waitStatus); dwWaitResult_1 = *(_WORD *)waitStatus; if ( !fork() ) labelExit_MulResult_MustBe_0: exit((dwWaitResult_1 & 0xFF00) == 0); wait((__WAIT_STATUS)waitStatus); if ( !waitStatus[1] ) goto labelReturn0; dwPlus_lhv = pContext->dwFieldSize9[0]; dwPlus_rhv = pContext->dwFieldSize9[5]; if ( !fork() ) goto labelExit_Plus; wait((__WAIT_STATUS)waitStatus); v22 = waitStatus[1]; if ( !fork() ) exit(v22 == 30); wait((__WAIT_STATUS)waitStatus); if ( !waitStatus[1] ) goto labelReturn0; c3 = pContext->dwFieldSize9[3]; c8 = pContext->dwFieldSize9[8]; if ( !fork() ) exit(c8 - c3); wait((__WAIT_STATUS)waitStatus); valueMustBe6 = waitStatus[1]; if ( !fork() ) exit(valueMustBe6 == 6); wait((__WAIT_STATUS)waitStatus); return waitStatus[1] != 0; // ここで1を返してほしい }
読解や理解の結果です:
- メインループ開始前に呼び出している関数(上のコードでは
InitializeGlobalByteArrayWithFixedValues()
と命名)で、グローバル変数の配列にバイト列を格納します。なお、srand
関数に固定値を与えた後にrand
関数を使用しているため、配列の格納結果は常に同一になります。 - マルバツゲームのように盤面を更新します。
- マルバツを置く処理で、
=
ではなく+=
を使用しているため、盤面には任意の値を格納できることが特筆すべき点です。 - マルバツゲームの勝敗がつくと勝敗表示してプロセスが終了するため、フラグは得られません。
- マルバツを置く処理で、
main
関数最後の方や、そこからさらに呼び出している関数(上のコードではIsCorrectBoardState()
と命名)で、盤面が特定の状態であるか検証します。特定の状態であれば、メインループ開始前に構築した配列と、現在の盤面の状態を元にフラグを復号して表示します。
(2023/04/23(日) 23:40頃追記)以下で取得する内容はソルバーで使いません。不要な手順です。最初は「フラグ復号を自分でやろう」と考えていたので取得していましたが、最終的にプログラムにフラグを出力させる方針へ切り替えたので、使わなくなりました。とはいえgdbの使い方の参考になれば幸いなので、文章としては残します。
メインループ開始前に構築する配列内容は、gdbデバッガーで以下の手順で取得しました:
calloc
関数呼び出後に配列構築関数を呼び出しているため、b calloc
でcalloc
関数へブレークポイントを設定します。run
でプロセスを実行し、calloc
関数でブレークしたらfin
で抜けます。si
を数回実行して、配列構築関数へ突入します。- 最初の方に、IDAで言う
lea r12, g_keysSize140+44h
があるので、その命令までni
で実行します。- ここで、r12レジスタは「グローバル変数の配列の先頭アドレス + 0x44」である点に注意してください。
- gdbの表示では
lea r12,[rip+0x2fdd]
と、グローバル変数からのオフセットだとは認識していませんが、同じ命令を表している点にも注意してください。
r12
レジスタの値をメモします。fin
で関数を抜けるまで実行させ、グローバル変数の配列内容を構築させます。x/160c (<さきほどメモしたr12レジスタの値> - 0x44)
で、構築したグローバル配列内容を取得します。
(2023/04/23(日) 23:40頃追記)ここまで不要な手順です。
ここまで分かった内容を使ってソルバーを書きました。fork
、exit
、wait
を使った特定の盤面状態を計算するためにz3ライブラリを使用し、特定の盤面状態へ遷移させてフラグを表示させるためにpwntoolsライブラリを使用しました。z3の制約式の入力間違いが怖かったですが、幸い一発で正しく入力できました:
#!/usr/bin/env python3 import z3 import pwn # pwn.context.log_level = "DEBUG" def parse(s): return bytes(list(map(lambda x: int(x, 16), s.replace("\n", " ").split()))) board = z3.IntVector("board", 9) s = z3.Solver() for i in range(9): s.add(board[0] >= 0) # main関数や特定の盤面状態か検証する関数での式を制約としてSATを解きます s.add(board[0] + board[3] == 19) # .text:0000000000001486 s.add(board[3] - board[6] == 0xF7-0x100) # .text:00000000000015ED s.add(board[6] / board[1] == 4) # .text:0000000000001628 s.add(board[4] + board[1] == 9) # .text:0000000000001666 s.add(board[2] * board[7] == 0) # .text:0000000000001704 s.add(board[2] - board[5] == 0xF2-0x100) # .text:000000000000171C s.add(board[5] - board[8] == 5) # .text:000000000000175E s.add(board[8] / board[0] == 0) # .text:000000000000179F s.add(board[0] - board[6] == 4) # .text:00000000000017E0 s.add(board[3] * board[1] == 9) # .text:0000000000001822 s.add(board[4] + board[6] == 18) # .text:0000000000001864 s.add(board[7] + board[1] == 12) # .text:00000000000018A6 s.add(board[4] - board[2] == 6) # .text:00000000000018E8 s.add(board[7] / board[5] == 0) # .text:000000000000192A s.add(board[2] * board[8] == 0) # .text:000000000000196C s.add(board[0] + board[5] == 30) # .text:00000000000019AD s.add(board[8] - board[3] == 6) # .text:0000000000001A92 if s.check() != z3.sat: raise Exception("FAILED TO SOLVE SAT!") m = s.model() print(m) actual_board = list(map(lambda cell: int(str(m.eval(cell))), board)) # z3結果から値を取る方法がよくわからず print(actual_board) def create_hand_command(cell): column = "abc"[cell//3] row = "123"[cell%3] return column + row with pwn.process("./challenge") as io: # pnwパートの試行錯誤中はz3計算結果をハードコードしていました # rest = [16, 3, 0, 3, 6, 14, 12, 9, 9] rest = actual_board current = 1 while True: print(rest) for (i, cell) in enumerate(rest): if cell >= current: io.sendlineafter(b"Enter position (e.g. a1):", create_hand_command(i).encode()) rest[i] -= current break else: print(io.recvall().decode()) break current = 2 if current == 1 else 1
実行しました:
$ ./solve.py [board__3 = 3, board__6 = 12, board__8 = 9, board__5 = 14, board__7 = 9, board__0 = 16, board__1 = 3, board__4 = 6, board__2 = 0, div0 = [(12, 3) -> 4, else -> 0], mod0 = [(12, 3) -> 0, else -> 9]] [16, 3, 0, 3, 6, 14, 12, 9, 9] [+] Starting local process './challenge': pid 14918 [16, 3, 0, 3, 6, 14, 12, 9, 9] [15, 3, 0, 3, 6, 14, 12, 9, 9] [13, 3, 0, 3, 6, 14, 12, 9, 9] [12, 3, 0, 3, 6, 14, 12, 9, 9] [10, 3, 0, 3, 6, 14, 12, 9, 9] [9, 3, 0, 3, 6, 14, 12, 9, 9] [7, 3, 0, 3, 6, 14, 12, 9, 9] [6, 3, 0, 3, 6, 14, 12, 9, 9] [4, 3, 0, 3, 6, 14, 12, 9, 9] [3, 3, 0, 3, 6, 14, 12, 9, 9] [1, 3, 0, 3, 6, 14, 12, 9, 9] [0, 3, 0, 3, 6, 14, 12, 9, 9] [0, 1, 0, 3, 6, 14, 12, 9, 9] [0, 0, 0, 3, 6, 14, 12, 9, 9] [0, 0, 0, 1, 6, 14, 12, 9, 9] [0, 0, 0, 0, 6, 14, 12, 9, 9] [0, 0, 0, 0, 4, 14, 12, 9, 9] [0, 0, 0, 0, 3, 14, 12, 9, 9] [0, 0, 0, 0, 1, 14, 12, 9, 9] [0, 0, 0, 0, 0, 14, 12, 9, 9] [0, 0, 0, 0, 0, 12, 12, 9, 9] [0, 0, 0, 0, 0, 11, 12, 9, 9] [0, 0, 0, 0, 0, 9, 12, 9, 9] [0, 0, 0, 0, 0, 8, 12, 9, 9] [0, 0, 0, 0, 0, 6, 12, 9, 9] [0, 0, 0, 0, 0, 5, 12, 9, 9] [0, 0, 0, 0, 0, 3, 12, 9, 9] [0, 0, 0, 0, 0, 2, 12, 9, 9] [0, 0, 0, 0, 0, 0, 12, 9, 9] [0, 0, 0, 0, 0, 0, 11, 9, 9] [0, 0, 0, 0, 0, 0, 9, 9, 9] [0, 0, 0, 0, 0, 0, 8, 9, 9] [0, 0, 0, 0, 0, 0, 6, 9, 9] [0, 0, 0, 0, 0, 0, 5, 9, 9] [0, 0, 0, 0, 0, 0, 3, 9, 9] [0, 0, 0, 0, 0, 0, 2, 9, 9] [0, 0, 0, 0, 0, 0, 0, 9, 9] [0, 0, 0, 0, 0, 0, 0, 8, 9] [0, 0, 0, 0, 0, 0, 0, 6, 9] [0, 0, 0, 0, 0, 0, 0, 5, 9] [0, 0, 0, 0, 0, 0, 0, 3, 9] [0, 0, 0, 0, 0, 0, 0, 2, 9] [0, 0, 0, 0, 0, 0, 0, 0, 9] [0, 0, 0, 0, 0, 0, 0, 0, 8] [0, 0, 0, 0, 0, 0, 0, 0, 6] [0, 0, 0, 0, 0, 0, 0, 0, 5] [0, 0, 0, 0, 0, 0, 0, 0, 3] [0, 0, 0, 0, 0, 0, 0, 0, 2] [0, 0, 0, 0, 0, 0, 0, 0, 0] [+] Receiving all data: Done (47B) [*] Process './challenge' stopped with exit code 0 (pid 14918) Congrats! RicSec{t1c_t4c_t03_1s_3x1t1ng_g4m3} $
フラグを入手できました: RicSec{t1c_t4c_t03_1s_3x1t1ng_g4m3}
[reversing] RSLocker (4 solves, 393 points)
とあるランサムウェアが世界中に広がっている。 我々はそのマルウェアのスクリーンロッカーの抽出に成功した。 解除コードを見つけるのを手伝ってほしい。 * 配布プログラムには悪意のあるコードが含まれている可能性があります。動作を理解していない限り実行しないでください。
配布ファイルとして、rslocker.exe
がありました:
$ file * rslocker.exe: PE32+ executable (GUI) x86-64, for MS Windows $
IDAで開いて読み進めると、以下の機能を持つことが分かりました:
- 新しいデスクトップを作成し、そのデスクトップへ切り替えます。
- 切り替え先のデスクトップでダイアログを表示し、フラグ入力を促します。別にある「Mercy」ボタンを押すと、お情けとして元のデスクトップへ戻って終了します。
- 切り替え先のデスクトップはExplorerも何もないデスクトップです。Explorerも何もないため、このプログラムが元に戻さない限り、ユーザーは何もできません。問題文にある通り、スクリーンロッカーとして機能します。お情け機能がなかったら本当に困るやつです。
- 起動時や、フラグ判定計算に使うThread Local Storage(TLS)用変数の初期化等で、デバッガー検知処理があります。
フラグ判定の中心となるダイアログプロシージャは以下の内容でした:
INT_PTR __fastcall DialogFunc(HWND hDlg, int msg, WPARAM wParam, LPARAM lParam) { WPARAM v5; // r8 unsigned int dwValue2; // edi unsigned int dwValue1; // ebx DWORD dwCurrentThreadId; // eax HDESK hThreadDesktop; // rax __int64 dwStrDlgItemTextLength; // rax int dwProcessedBytes; // r8d unsigned __int64 dwStrDlgItemTextLengthPlus1; // rax __int64 dwProcessedBytes_1; // rdx DWORD nLengthNeeded; // [rsp+30h] [rbp-248h] BYREF char strDesktopNameSize16[16]; // [rsp+38h] [rbp-240h] BYREF __int128 v20; // [rsp+48h] [rbp-230h] char strDlgItemTextSize256[256]; // [rsp+60h] [rbp-218h] BYREF char byteArrayInputedAndConvertedSize64[256]; // [rsp+160h] [rbp-118h] BYREF if ( msg != WM_COMMAND ) return 0i64; v5 = wParam - 1; if ( v5 ) { if ( v5 == 2 ) { MessageBoxW(hDlg, L"I'll let you go this time.", L"RSLocker", 0x40040u); EndDialog(hDlg, 1i64); return 1i64; } return 0i64; } memset(strDlgItemTextSize256, 0, sizeof(strDlgItemTextSize256)); GetDlgItemTextA(hDlg, 104, strDlgItemTextSize256, 256); *(_OWORD *)strDesktopNameSize16 = 0i64; v20 = 0i64; dwValue2 = (unsigned int)TlsGetValue(g_dwTlsIndexForValue2_0xBB40E64D_IfNotDebugged); dwValue1 = (unsigned int)TlsGetValue(g_dwTlsIndexForValue11_0xA205B064_IfNotDebugged); dwCurrentThreadId = GetCurrentThreadId(); hThreadDesktop = GetThreadDesktop(dwCurrentThreadId); GetUserObjectInformationW(hThreadDesktop, UOI_NAME, strDesktopNameSize16, 0xFu, &nLengthNeeded); dwStrDlgItemTextLength = -1i64; _XMM3 = _mm_unpacklo_epi64( _mm_unpacklo_epi32( _mm_cvtsi32_si128(_mm_crc32_u32(dwValue1, *(unsigned int *)&strDesktopNameSize16[4])), _mm_cvtsi32_si128(_mm_crc32_u32(dwValue1, *(unsigned int *)strDesktopNameSize16))), _mm_unpacklo_epi32( _mm_cvtsi32_si128(_mm_crc32_u32(dwValue2, *(unsigned int *)&strDesktopNameSize16[4])), _mm_cvtsi32_si128(_mm_crc32_u32(dwValue2, *(unsigned int *)strDesktopNameSize16)))); do ++dwStrDlgItemTextLength; while ( strDlgItemTextSize256[dwStrDlgItemTextLength] ); dwProcessedBytes = 0; dwStrDlgItemTextLengthPlus1 = dwStrDlgItemTextLength + 1; if ( dwStrDlgItemTextLengthPlus1 ) { dwProcessedBytes_1 = 0i64; do { _XMM0 = _mm_loadu_si128((const __m128i *)&strDlgItemTextSize256[dwProcessedBytes_1]); dwProcessedBytes_1 += 16i64; dwProcessedBytes += 16; __asm { aesenc xmm0, xmm3 } *(_OWORD *)&strDlgItemTextSize256[dwProcessedBytes_1 + 240] = _XMM0;// 16+240==256というわけで、暗号化結果は入力とは重ならない場所へ書き込まれる } while ( dwProcessedBytes < dwStrDlgItemTextLengthPlus1 ); } if ( !memcmp(byteArrayInputedAndConvertedSize64, g_expectedBytesSize64, 0x40ui64) ) { MessageBoxW(hDlg, L"Successfully unlocked. Thank you for paying the money.", L"RSLocker", 0x40040u); EndDialog(hDlg, 1i64); } else { MessageBoxW(hDlg, L"Invalid code", L"RSLocker", 0x40010u); } return 1i64; }
入力されたフラグの加工を、SIMD命令やaesenc
命令で加工し、固定の64バイトと一致するかを検証しています。aesenc
命令の挙動を調べると、(PDF直リン)Intel® Advanced Encryption Standard (AES) New Instructions SetのFigure 9. The AESENC and AESENCLAST Instructions
箇所に、以下の記述がありました:
AESENC xmm1, xmm2/m128 Tmp := xmm1 Round Key := xmm2/m128 Tmp := ShiftRows (Tmp) Tmp := SubBytes (Tmp) Tmp := MixColumns (Tmp) xmm1 := Tmp xor Round Key
ShiftRows等の詳細はAdvanced Encryption Standard - Wikipedia等をご参照ください。今回のaesenc
命令の使い方を見るに、xmm3レジスタ内容をRoundKeyとして、xmm0レジスタの内容にShiftRows、SubBytes、MixColumns、AddRoundKeyを施す内容と分かります。これらと前述のコードを組み合わせると、xmm0レジスタ内容はフラグ入力内容のみに依存することが分かります。また、xmm3レジスタはループ中で変化しないため、全ブロックについて同一のAddRoundKeyが適用されることも分かります。
AES処理の中でもよりプリミティブな処理を使用しているため、逆演算をどのように行うかしばらく考えました。まず、聞こえが逆演算なaesdec
命令の処理内容が、aesenc
命令のちょうど逆演算に対応するか調べてみました。しかし、残念ながらそれぞれがInv版になった処理内容であり、aesenc
の逆演算ではありませんでした:
AESDEC xmm1, xmm2/m128 Tmp := xmm1 Round Key := xmm2/m128 Tmp := InvShift Rows (Tmp) Tmp := InvSubBytes (Tmp) Tmp := InvMixColumns (Tmp) xmm1 := Tmp xor Round Key
色々検討や試行錯誤を繰り返して、最終的にpcaro90/Python-AES: Pure Python AES standard implementationを使用しました。最後のソルバーで使います。
TlsCallBackの処理に話を戻すと、aesenc
命令で使うxmm3レジスタの内容、すなわちAddRoundKey用の鍵の値は、以下の要素で構成されていることが分かります。
- TLSを利用した値の1つ目。
- この設定値は、
NtCurrentPeb()->BeingDebugged
の内容が0かどうかで変化させています。Anti-Debug: Debug Flagsによると、デバッグされているときは非0になるとのことなので、今回の問題では0の場合に格納される値が正解の鍵の構成要素と判断できます。
- この設定値は、
- TLSを利用した値の2つ目。
- この設定値は
NtCurrentPeb()->NtGlobalFlag
の内容が0かどうかで変化させています。Anti-Debug: Debug Flagsによると、こちらも同様にデバッグされているときは非0になるとのことなので、今回の問題では0の場合に格納される値が正解の鍵の構成要素と判断できます。
- この設定値は
GetUserObjectInformationW(GetThreadDesktop(GetCurrentThreadId()), UOI_NAME, strDesktopNameSize16, 0xFu, &nLengthNeeded)
で取得する、現在のデスクトップの名前。- 前述したように、このプログラムは新しいデスクトップを作成して切り替えます。そのため作成するデスクトップの名前が鍵の構成要素になります。
これらの要素のうち、デスクトップ名で長時間悩まされました。新しいデスクトップの作成は、TlsCallbackで以下のように行っているとIDAは表示しました:
hCreatedDesktop = CreateDesktopW("m0S0g0Y0", 0i64, 0i64, 0, 0x1F018Bu, 0i64);
一見、デスクトップ名をm0S0g0Y0
に指定しているように見えます。しかしよくよく注意深く見てみると、CreateDesktopW
という末尾WのUnicode版(=WindowsにおけるUTF-16LE版)のAPIを使用しているにも関わらず、第1引数が"m0S0g0Y0"
というANSI文字列になっています。これはIDAは型を間違えているのであって、実際は"m0S0g0Y0"
と解釈されてしまうUTF-16LE文字列がデスクトップ名になります:
というわけで、実際に本プログラムで使用しているデスクトップ名はL"ねこです"
でした。なお、一度文字列の型を設定すると、逆コンパイル画面でも正しく表示してくれます:
hCreatedDesktop = CreateDesktopW(L"ねこです", 0i64, 0i64, 0, 0x1F018Bu, 0i64);
これで、aesenc
命令がAddRoundKey用途に使用するxmm3レジスタの構成要素が判明しました。アンチデバッグを回避しつつ、デバッガー実行でxmm3レジスタ等の内容を確認するため、パッチを当ててデバッガー実行しました:
- デバッガーでブレークポイントを設定しやすくするため、CFF ExplorerでEXEを開いて、Optional HeaderのDllCharacteristicsから「DLL can move」ビットをOFFに変更した状態で保存しました。
- 保存したEXEをIDAで開き、Hex ViewのEdit機能で処理内容を変更しました:
- 「.text:000000014000101B」の分岐をnopで潰して、
NtCurrentPeb()->NtGlobalFlag
を使ったデバッガー検知時の分岐を削除 - 「.text:0000000140001048」の分岐をnopで潰して、
NtCurrentPeb()->BeingDebugged
を使ったデバッガー検知時の分岐を削除 - 「.text:000000014000129F」の内容を
test eax, eax
からxor eax, eax
へ変更し、IsDebuggerPresent
を使ったデバッガー検知を無効化 - 「.text:0000000140001402」からある
SetThreadDesktop
関数、SwitchDesktop
関数呼び出しをnopで潰して、デスクトップ切り替え機能を削除 - メニューの「Edit→Patch Program→Apply patches to input file」から、変更内容をEXEへ反映
- 内容変更一覧です:
- 「.text:000000014000101B」の分岐をnopで潰して、
- x64dbgで本プログラムを実行し、
GetUserObjectInformationW
関数を呼び出している「.text:000000014000115C」へブレークポイントを設置し、適当なフラグを入力してブレークさせたら、第3引数のバッファ内容をL"ねこです"
、すなわち{6D 30 53 30 67 30 59 30 00 00}
へ変更します。 - これでようやくxmm3レジスタへ正しい鍵の情報が入るようになったので、
aesenc
命令のある「.text:00000001400011EE」へブレークポイントを設置して、実行を継続してブレークさせます。xmm3レジスタの値と、後でのソルバー検証用にaesenc
命令前後のxmm0レジスタの値をメモします。
長い道のりを経て分かったことを使って、フラグを計算するソルバーを書きました。aesenc
命令がxmmレジスタのどのバイトをAESブロックのどこに配置するか分からなかったので、結構な試行錯誤が必要になりました:
#!/usr/bin/env python3 import AES # https://github.com/pcaro90/Python-AES # aesenc命令の結果色々確認、値はx64dbgからXMM0、XMM3レジスタ内容をコピペしたものなのでエンディアンに注意。 # aesenc命令は「The 128-bit state and round key vectors are interpreted as 16-byte column-major entries in a 4-by-4 matrix of bytes.」とのこと # よく考えたらkeyは単にxorだけだし、全部0で差し支えなさそう patterns = [ # (plain, key, cipher) (0, 0, 0x63636363636363636363636363636363), (0, 1, 0x63636363636363636363636363636362), (1, 0, 0x636363636363636363636363427C7C5D), (0x100, 0, 0x7C7C5D42636363636363636363636363), (0x00000000000000000000000074736574, 0, 0x4D4D3F118FA04C8F9A6B92926B92929A), (0x00000000000000000000000074736574, 0x53B9422553B942250338B7B80338B7B8, 0x1EF47D34DC190EAA9953252A68AA2522), ] # 試行錯誤した結果、まっとうな行列への変換で合いました。 # column-majorとは一体……(使用したAESライブラリの都合かもしれない) def xmm_to_state(value): b = value.to_bytes(16, "little") return [ [b[0],b[1],b[2],b[3],], [b[4],b[5],b[6],b[7],], [b[8],b[9],b[10],b[11],], [b[12],b[13],b[14],b[15],], ] def state_to_xmm(state): x = 0 x = (x << 8) + state[3][3] x = (x << 8) + state[3][2] x = (x << 8) + state[3][1] x = (x << 8) + state[3][0] x = (x << 8) + state[2][3] x = (x << 8) + state[2][2] x = (x << 8) + state[2][1] x = (x << 8) + state[2][0] x = (x << 8) + state[1][3] x = (x << 8) + state[1][2] x = (x << 8) + state[1][1] x = (x << 8) + state[1][0] x = (x << 8) + state[0][3] x = (x << 8) + state[0][2] x = (x << 8) + state[0][1] x = (x << 8) + state[0][0] return x def encrypt_block(block, key): block = AES.ShiftRows(block) block = AES.SubBytes(block) block = AES.MixColumns(block) block = AES.AddRoundKey(block, key) return block def decrypt_block(block, key): block = AES.AddRoundKey(block, key) block = AES.InvMixColumns(block) block = AES.InvSubBytes(block) block = AES.InvShiftRows(block) return block for pattern in patterns: plain = xmm_to_state(pattern[0]) key = xmm_to_state(pattern[1]) cipher = xmm_to_state(pattern[2]) assert encrypt_block(plain, key) == cipher assert decrypt_block(cipher, key) == plain for i in range(10000): x = state_to_xmm(xmm_to_state(i)) # print(f"{hex(i) = } , {hex(x) = }") assert i == x def parse(s): return bytes(list(map(lambda x: int(x, 16), s.replace("\n", " ").split()))) # ↓TlsGetValue関係のAnti-Debugを回避し、GetUserObjectInformationWでのデスクトップ名取得も適切に行わせた場合のxmm3の値 key = xmm_to_state(0x2A29A1D7D95B12E67AA8544A89DAE77B) # ↓はIDAのHexViewからのコピペなのでXMMレジスタの値ではありません encrypted_flag = parse(""" 36 D7 8F 01 48 9B D3 3C 25 A3 2D 0B BF 76 84 BD 86 E9 52 28 F4 AF 18 71 E7 DD 38 64 CD EC 53 A8 56 8C 5F 18 65 13 5E E0 39 D9 80 12 CC 19 FD D9 7C B6 8B BC B5 AB 74 3A A3 1B 74 9C BC 3B BB B8 """) for i in range(4): block = xmm_to_state(int.from_bytes(encrypted_flag[i*16:(i+1)*16], "little")) flag = state_to_xmm(decrypt_block(block, key)) print(flag.to_bytes(16, "little").strip(b"\x00").decode(), end="") print()
実行しました:
$ ./solve.py RicSec{Windows_4nt1-d3bug_h4s_b33n_5tud13d_1n_d3pth} $
フラグを入手できました: RicSec{Windows_4nt1-d3bug_h4s_b33n_5tud13d_1n_d3pth}
Writeup賞
Rulesページに記述があったWriteup賞に、本ブログ記事を選んでくださいました!
ところでタンブラーをよく見ると、ロゴ部分に16進数が描かれています(写真にすべてを含めていないのは意図的です)。ロゴの最上段左端は48
であり、x86か何かの機械語命令が描かれているように思ったので、内容を確認することにしました。
まずは16進数を読み取ります。あまり大きくはないので人力で読み取りました。読み間違えが怖かったので、以下の点に注意して検証しました:
- 読み取り結果のスペース等を詰めて記述することはせず、ロゴの形状を保ったまま記述する。
- 2回個別に読み取り結果を記述し、
diff
コマンド等で同一であるか確認して、差がある箇所はロゴを再確認する。
$ diff tumbler_logo.txt tumbler_logo2.txt $
おそらく正しく読み取ることができました。
次に、ロゴ内容がどのアーキテクチャー向け(Windows/Linux、x86/x64)の機械語かを確認しました。IDA ProのFree版が読み込めるのはPE形式かELF形式のみであり、シェルコード内容のファイルそのものは読み込めません。とりあえずaccidentalrebel/shcode2exeで32-bit EXEを作成してIDAへ読み込ませると、以下のことが分かりました:
dec eax
などが多出しているので、おそらくそれはx64におけるREX prefix
らしい。- 最後の方に
syscall
命令が登場するので、Linux環境向けらしい。
そういうわけで次にやることは、シェルコードをx64 ELFに変換することだと分かりました。「Visual C++なら__asm
ブロック中で_emit
命令を使うと任意シェルコードを書けるので、gccでも同様の機能があるのでは」と調べると、gcc - What is the equivalent of _emit on Linux? - Stack Overflowが見つかりました。asm __volatile__ (".byte 0x12");
のよう書けるとのことです。そういうわけで変換スクリプトを書きました:
#!/usr/bin/env python3 with open("tumbler_logo.txt") as f: data = bytes.fromhex(f.read()) print("int main(void) {") for b in data + bytes(([0xCC]*16)): # 終端が分かりやすくなるようint 3を適当に追加 print(f'asm __volatile__ (".byte 0x{b:02X}");') print("}")
とりあえず出力結果をコンパイルして確認すると、何か色々した後に標準出力へ書き込む内容のようでした。実行してみました:
$ ./convert_tumbler_logo_content_to_c_code.py > code.c $ gcc code.c $ ./a.out # ### # # # ### ### ### ##### ### ### ### ### ##### ### ### ### ### ##### ### ### ### # # # ### # Ricerca CTF $
リチェルカセキュリティ様のロゴを表示する内容のようです。IDAで読み込んで読解したり、デバッグ実行したりすると、以下のことが分かりました:
loop
命令が使われているので、人手で書かれたコードらしい。rsi
レジスタ内容をコード中に設定、rdi
レジスタ内容をスタックに設定して、lodsb
命令とstosb
命令を使って何かを復号していそう。- 途中に巨大な数値でよく分からない演算をしている箇所がありますが、どうにもそこは実際には実行されなさそう。すなわち
rsi
レジスタ経由で読み取るだけの場所らしい。
そういうわけで、別途フラグ等が埋め込まれていたりはしなさそうです。せっかくなので読み取り結果をソルバーに起こしてみました:
#!/usr/bin/env python3 rsi = bytearray.fromhex("E1F0(意図的に省略)4300") rdi = bytearray() while True: al = rsi[0] del rsi[0] if al == 0: break al ^= 0xAE for _ in range(al & 0x07): rdi.append(0x20) for _ in range((al >> 3) & 0x07): rdi.append(0x23) if (((al - 0x40) + 0x80) & 0xFF) & 0x80: # jb(=CF)があまり分かっていません rdi.append(0x0a) if al == 0x40: raise Exception("I think this code should not be executed.") for _ in range(0x17): al = rsi[0] del rsi[0] rdi.append(al ^ 0x49) if len(rdi) != 0xa7: raise Exception("Something wrong...") print(rdi.decode(), end="")
実行しました:
$ ./solve.py # ### # # # ### ### ### ##### ### ### ### ### ##### ### ### ### ### ##### ### ### ### # # # ### # Ricerca CTF $
直接実行した場合と同一の結果を得られました。
感想
- reversingジャンルを全問正解できて大満足です!
- 日本企業が開催するだけあって、12時間と一般的なCTFと比べて短い時間ではありましたが、日本標準時で過ごす人にとって就寝時間を挟まない、取り組みやすい時間帯なのがありがたかったです。
- x86やx64ではないInstruction Set Architectureは、普段とは異なるツールを使うことになったり、そもそも慣れるところから始まるのでちょっと大変でした。けれど新しい領域の知識が得られるのは面白いです。
- z3を初めてまともに使った気がします。部分問題を解くための強力なツールだと思いました。
aesenc
命令関係でSIMDの使い方を少し調べていましたが、全く理解できないまま挫折しました。似たような型や命令があまりにも多すぎて難しいですね……。- EXE等のPE形式でしたらDll can moveビットを触ることで実行時アドレスを固定できるのでブレークポイント設定が簡単になりますが、PIEなELFだとブレークポイント設定が大変ですです。何かうまい方法は無いでしょうかね……。
- 今回のCTFで、初めてdockerを使ってみました。ローカル環境で問題環境を作れてとても便利でした!一度嬉しさが分かると、DockerFile等を配布してくださる問題がとてもありがたいものだとも分かりました。
- Writeup賞に選んでいただきありがとうございます!