この記事は、2023年4月22日に弊社が開催したRicerca CTF 2023の公式Writeupです。今回はReversingカテゴリの問題のうち、warmupを除く問題の解法を紹介します。
配布ファイルやスクリプトは以下のGitHubリポジトリを参照してください。
https://github.com/RICSecLab/ricerca-ctf-2023-public
また、他のジャンルのWriteupは以下の投稿から読むことができます。
問題・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つの関数checkFlag
、F
、main
が含まれていることがわかります。
まず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}}}")
問題・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
関数の中はfork
、exit
、wait
の呼び出しが連なっており、難読化されています。これらの呼び出しを慎重に読むと、子プロセスの終了ステータスを介して何らかの計算を行っていることがわかります。たとえば、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()
問題・Writeup著者:ptr-yudai
ランサムウェアのスクリーンロッカー部分が抽出されているので、これを解析してロックを解除するコードを特定するcrackme系の問題です。 VM上で実際に配布された実行ファイルを起動してみると、デスクトップが切り替わり、次のような画面になります。
この問題ではWindowsのデスクトップに関する知識が重要になります。
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領域に保存される値に変化があります。
crc32
やaesenc
といったマイナーな命令を使って暗号化しています。
注意点として、aesenc
の逆操作をする命令は存在しません。仕様を参考に復号処理を書きましょう。
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)