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

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

Ricerca CTF 2023 write-up (reversingジャンル全問)

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問全問解けました!

reversingジャンル全問正解

環境

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
$

crackmeIDAで開いて逆コンパイルすると、以下の内容でした:

__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つの関数maincheckFlagFがありました。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セクション

.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 calloccalloc関数へブレークポイントを設定します。
  • runでプロセスを実行し、calloc関数でブレークしたらfinで抜けます。
  • siを数回実行して、配列構築関数へ突入します。
  • 最初の方に、IDAで言うlea r12, g_keysSize140+44hがあるので、その命令までniで実行します。
    • ここで、r12レジスタは「グローバル変数の配列の先頭アドレス + 0x44」である点に注意してください。
    • gdbの表示ではlea r12,[rip+0x2fdd]と、グローバル変数からのオフセットだとは認識していませんが、同じ命令を表している点にも注意してください。
  • r12レジスタの値をメモします。
  • finで関数を抜けるまで実行させ、グローバル変数の配列内容を構築させます。
  • x/160c (<さきほどメモしたr12レジスタの値> - 0x44)で、構築したグローバル配列内容を取得します。

gdbで見るグローバル変数配列の内容(表示はpedaによるものです)

(2023/04/23(日) 23:40頃追記)ここまで不要な手順です。

ここまで分かった内容を使ってソルバーを書きました。forkexitwaitを使った特定の盤面状態を計算するために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 SetFigure 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へ反映
    • 内容変更一覧です:

プログラム変更内容

  • x64dbgで本プログラムを実行し、GetUserObjectInformationW関数を呼び出している「.text:000000014000115C」へブレークポイントを設置し、適当なフラグを入力してブレークさせたら、第3引数のバッファ内容をL"ねこです"、すなわち{6D 30 53 30 67 30 59 30 00 00}へ変更します。
  • これでようやくxmm3レジスタへ正しい鍵の情報が入るようになったので、aesenc命令のある「.text:00000001400011EE」へブレークポイントを設置して、実行を継続してブレークさせます。xmm3レジスタの値と、後でのソルバー検証用にaesenc命令前後のxmm0レジスタの値をメモします。

xmmレジスタ内容の値確認に成功

長い道のりを経て分かったことを使って、フラグを計算するソルバーを書きました。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賞に、本ブログ記事を選んでくださいました!

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賞に選んでいただきありがとうございます!