Thursday, June 1, 2023

Ricerca CTF 2023 作問者Writeup [Reversing編]

この記事は、2023年4月22日に弊社が開催したRicerca CTF 2023の公式Writeupです。今回はReversingカテゴリの問題のうち、warmupを除く問題の解法を紹介します。

配布ファイルやスクリプトは以下のGitHubリポジトリを参照してください。

https://github.com/RICSecLab/ricerca-ctf-2023-public

  また、他のジャンルのWriteupは以下の投稿から読むことができます。

ignition

問題・Writeup著者: arata-nvm

概要

引数にフラグを与えると、そのフラグが正しいか判定するプログラムが与えられます。Dockerfileを読むとchallenge.jscが本体であり、bytenodeというプログラムを使って実行していることがわかります。

FROM node:8
RUN npm i -g bytenode
COPY challenge.jsc /
ENTRYPOINT ["bytenode", "/challenge.jsc"]

解法

「bytenode decompile」などで検索するとghidra_nodejsというGhidraのプラグインが見つかります。Releasesにビルド済みのzipファイルが置かれているので、問題のヒントで指定されたGhidra v9.2.2にインストールしてchallenge.jscをデコンパイルします。すると、プログラムには主に3つの関数checkFlagFmainが含まれていることがわかります。

まずmain関数をJavaScriptのコードに直します。

function main() {
  if (process.argv.length != 3) {
    console.log("Usage: check.sh <flag>");
    process.exit(1);
  }

  const flag = process.argv[2];
  if (checkFlag(flag)) {
    console.log("Correct")
  } else {
    console.log("Wrong")
  }
}

引数として与えられたフラグをcheckFlag関数に渡し、その戻り値をチェックしています。つまり、checkFlag関数がtrueを返す条件を調べるとよさそうです。

ここでcheckFlag関数を読む際に難しいポイントが1つあります。配列を作成した後にStar命令でencodedを参照していますが、デコンパイル結果からはencodedの中身がわかりません。

ではその中身はどこに保存されているのかというと、GhidraのProgram Tree Viewから、.arrsセクションを見ることで発見できます。以下の画像の通り、28個の要素を持つ配列が置かれています。

ここまでを踏まえてcheckFlag関数をJavaScriptに直すと以下のようになります。

function checkFlag(s) {
  const encoded = [49, 102, 111, 51, 119, 52, 56, 99, 74, 64, 78, 45, 245, 138, 73, 6, 190, 98, 41, 38, 50, 177, 31, 174, 82, 32, 82, 42];

  if (s.length != 36) {
    return false;
  }

  if (s.slice(0, 7) != "RicSec{" || s.slice(-1) != "}") {
    return false;
  }

  for (let i = 0; i < 28; i++) {
    let e = s.charCodeAt(i + 7) ^ (F(i) & 0xff);
    if (e != encoded[i]) {
      return false;
    }
  }

  return true;
}

与えられたフラグとF関数の戻り値のXORをとり、その結果を定数配列encodedと比較しています。F関数を調べると、フィボナッチ数列を計算していることがわかります。

function F(n) {
  if (n == 0) {
    return 0;
  } else if (n == 1) {
    return 1;
  } else {
    return F(n - 1) + F(n - 2);
  }
}

つまり、以下のように配列encodedの要素とフィボナッチ数列のXORを計算するとフラグが得られます。

def F(n):
    if n == 0:
        return 0
    elif n == 1:
        return 1
    else:
        return F(n - 1) + F(n - 2);

encoded = [49, 102, 111, 51, 119, 52, 56, 99, 74, 64, 78, 45, 245, 138, 73, 6, 190, 98, 41, 38, 50, 177, 31, 174, 82, 32, 82, 42];
flag = "".join([chr(c ^ (F(i) & 0xff)) for i, c in enumerate(encoded)])
print(f"RicSec{{{flag}}}")

 

[Reversing] tic tac toe?

問題・Writeup著者: arata-nvm

概要

与えられたバイナリを実行するとoxゲームが始まります。プレイヤーが座標を入力すると、そのマスにoもしくはxのいずれかのマークが置かれます。ただし、一般的なoxゲームと異なり、すでにマークが置かれているマスにもマークを置くことができます。

Tic-Tac-Toe  

  | a | b | c |
--|---|---|---|
1 |   |   |   |
--|---|---|---|
2 |   |   |   |
--|---|---|---|
3 |   |   |   |
--|---|---|---|

Player 1's turn.
Enter position (e.g. a1):

解法

IDAにバイナリを読み込ませてStrings Viewを見ると、Congrats!という文字列が存在していることがわかります。この文字列はsub_2140関数内で参照されており、この関数が呼び出されればフラグが表示されそうです。

void __fastcall __noreturn sub_2140(__int64 a1)
{
(略)
      puts("Congrats!");
      puts(aVmgwag);
      exit(0);
(略)
}

ではいつsub_2140関数が呼び出されるのかを調べると、main関数の下部でsub_1590関数がtrueを返したときにsub_2140(v5);の行が実行されています。

if ( !fork() )
      exit(v12 + v10);
    wait((__WAIT_STATUS)&v19);
    v17 = BYTE1(v19);
    if ( !fork() )
      exit(v17 == 19);
    wait((__WAIT_STATUS)&v19);
    if ( BYTE1(v19) && (unsigned int)sub_1590(v5) )
      sub_2140(v5);

しかしsub_1590関数の中はforkexitwaitの呼び出しが連なっており、難読化されています。これらの呼び出しを慎重に読むと、子プロセスの終了ステータスを介して何らかの計算を行っていることがわかります。たとえば、c = a + bは以下のように計算されています。

if ( !fork() )
    exit(a + b);
wait((__WAIT_STATUS)&status);
c = BYTE1(status);

次に、sub_1590関数で何の計算が行われているのかを知るために、ゲームの状態を保持する構造体を調べる必要があります。盤面を表示するsub_1C40関数などから推測すると、その構造体は以下のような定義であることがわかります。

struct {
	int board[9]
	int current_player;
}

ここまでの情報をまとめると、sub_1590関数ではboardの要素を使って計算を行い、その結果がある特定の値と一致すればtrueを返すという動作をしているようです。つまりこの問題を解くためにはプレイヤーがboardの値を任意に設定できる必要がありますが、マークを置く処理を眺めるとそれは可能であることがわかります。main関数内に以下のような処理があり、マスにoを置くと1が、xを置くと2が加算されています。

v5->board[3 * v8 + v9] += v5->current_player;

したがって、適切にマスにマークを置くのを繰り返し、boardの値をうまく変えてやればフラグを表示する処理が実行されそうです。

以下、ソルバーの説明をします。まず、sub_1590関数の条件を満たすために、boardの各要素がどのような値になっていればよいかを調べます。

from z3 import *

s = Solver()

cells = [BitVec(f"cell{i}", 8) for i in range(9)]
def cell_at(x, y):
    return cells[x * 3 + y]

s.add(cell_at(0, 0) + cell_at(1, 0) == 19)
s.add(cell_at(1, 0) - cell_at(2, 0) == 247)
s.add(cell_at(2, 0) / cell_at(0, 1) == 4)
s.add(cell_at(0, 1) + cell_at(1, 1) == 9)
s.add(cell_at(1, 1) * cell_at(2, 1) == 54)
s.add(cell_at(2, 1) * cell_at(0, 2) == 0)
s.add(cell_at(0, 2) - cell_at(1, 2) == 242)
s.add(cell_at(1, 2) - cell_at(2, 2) == 5)
s.add(cell_at(2, 2) / cell_at(0, 0) == 0)
s.add(cell_at(0, 0) - cell_at(2, 0) == 4)
s.add(cell_at(1, 0) * cell_at(0, 1) == 9)
s.add(cell_at(2, 0) + cell_at(1, 1) == 18)
s.add(cell_at(0, 1) + cell_at(2, 1) == 12)
s.add(cell_at(1, 1) - cell_at(0, 2) == 6)
s.add(cell_at(2, 1) / cell_at(1, 2) == 0)
s.add(cell_at(0, 2) * cell_at(2, 2) == 0)
s.add(cell_at(1, 2) + cell_at(0, 0) == 30)
s.add(cell_at(2, 2) - cell_at(1, 0) == 6)

if s.check() == unsat:
    exit('unsat')

print('sat')
m = s.model()
print([m[cells[i].as_long() for i in cells])

その結果をもとにマスにマークを置くと、フラグが得られます。

from pwn import *

p = process("./challenge")

val = [16, 3, 12, 3, 6, 9, 0, 14, 9]
cells = ['a1', 'b1', 'c1', 'a2', 'b2', 'c2', 'a3', 'b3', 'c3']

turn = 0;
for i in range(9):
  v = 0
  while v != val[i]:
    p.sendlineafter(b": ", cells[i].encode())
    v = v + [1, 2][turn % 2]
    turn = turn + 1

p.interactive()


[reversing] RSLocker

問題・Writeup著者:ptr-yudai

問題概要

ランサムウェアのスクリーンロッカー部分が抽出されているので、これを解析してロックを解除するコードを特定するcrackme系の問題です。 VM上で実際に配布された実行ファイルを起動してみると、デスクトップが切り替わり、次のような画面になります。

解法

この問題ではWindowsのデスクトップに関する知識が重要になります。

TLS callback

TLS (Thread Local Storage)はスレッド固有のメモリ領域です。Windowsでは、TLS callbackという機能を利用して、DLLがアタッチ・デタッチされたときや、スレッドが作成・破棄されたときにメッセージを受け取ることができます。 このプログラムでは、TLS callbackを使って起動時(DLLアタッチ時)に処理を実行しています。


ウィンドウステーションとデスクトップ

Windowsにおいて、デスクトップというのはセキュリティ的な意味を持っています。

我々が普段利用している「デスクトップ」はウィンドウステーションと呼ばれるオブジェクトが保持しています。ウィンドウステーションはWindows起動時にwinlogon.exeがログインユーザーごとに作成します。この際作成されるウィンドウステーション(対話型)は「WinSta0」という名前を持っています。

デスクトップはウィンドウのようなユーザーインタフェースを持っており、ウィンドウステーションは複数のデスクトップを持つことができます。デスクトップはセキュリティ保護が可能で、一般的にあるデスクトップから他のデスクトップにウィンドウメッセージは送信できません。

先述のTLS callbackで、デスクトップを作成して切り替える処理が見つかります。

今回のスクリーンロッカーは、新しいデスクトップに切り替えることで、元の画面に戻れないような実装になっています。

動的解析

デスクトップが切り替わってしまうため、デバッグ時に問題が発生します。 デバッガでプログラムを起動しても、別のデスクトップへ移るためデバッガが使えません。したがって、動的解析するためには先ほどのTLS callback中のSwitchDesktopを消す必要があります。

静的解析

静的解析すると、いくつかのアンチデバッグ技術が登場します。 まずIsDebuggerPresentによるデバッガ検知です。これはnopで削除しても特に影響はありません。

次にTLS callback中で設定されたタイマー関数が2つあります。

それぞれNtGlobalFlag, BeingDebuggedをチェックしています。こちらはデバッガを検知すると、TlsSetValueでTLS領域に保存される値に変化があります。

暗号処理

crc32aesencといったマイナーな命令を使って暗号化しています。 注意点として、aesencの逆操作をする命令は存在しません。仕様を参考に復号処理を書きましょう。

ソルバ

このソルバ ではptrlibを利用しています。
from ptrlib import *

k1 = 0xa205b064
k2 = 0xbb40e64d
desk = b"m0S0g0Y0"
cipher = bytearray.fromhex("36D78F01489BD33C25A32D0BBF7684BD86E95228F4AF1871E7DD3864CDEC53A8568C5F1865135EE039D98012CC19FDD97CB68BBCB5AB743AA31B749CBC3BBBB8")

key = flat([
    intel_crc32(k1, desk[4:]),
    intel_crc32(k1, desk[:4]),
    intel_crc32(k2, desk[4:]),
    intel_crc32(k2, desk[:4])
], map=p32)

flag = b""
for block in chunks(cipher, 16):
    flag += intel_aesenc_inv(block, key)
print(flag)

No comments:

Post a Comment