はじめに
前回の記事では、kfun__main 周辺を解析し、このプログラムが入力ファイルを8バイト単位で読み込み、生成したキーを使って暗号化していることを確認しました。
また、暗号化前の処理を追う中で、暗号キー生成関数の戻り値が Pair<String, List<?>> のような構造になっていることが分かりました。1つ目の値は出力ファイル名に使われる文字列、2つ目の値は暗号化処理に使われるキー配列であると考えられます。
この記事では、この暗号キー生成関数を詳しく解析し、ファイル名から初期暗号鍵を復元できることを確認していきます。
このWriteupの他の記事はこちら↓
- FWE CTF - NativeKotlian Writeup (フロー解析)
- FWE CTF - NativeKotlian Writeup (暗号キー生成関数)
- FWE CTF - NativeKotlian Writeup (暗号化ロジック)
- FWE CTF - NativeKotlian Writeup(暗号化ロジック2・キー更新・復号)
暗号キー生成
ここからは厳密にそれぞれの関数を解析し、本格的に復号のプログラムを書いていきます。
手始めに、キー生成の部分を解析します。
最初は、0~2の範囲の乱数を生成した直後にラムダ関数っぽいのを呼んでいます。
キー文字列生成関数
先ほどと同じようにデバッガーで関数を特定すると、このような関数にジャンプしています。
今のところ、このあたりが気になるところです。
...
if ( !v2 )
ThrowArithmeticException(&_unnamed_142, 0, v6, v7);
...
if ( *(v7 + 8) <= v8 )
ThrowArrayIndexOutOfBoundsException(&_unnamed_142);
ジャンプ前に戻って、*((_DWORD*)a+2)に該当するところを調べます。
先程の関数へ渡す際にparam + 8をしているので、今回は *(_DWORD *)(param + 16) = random_int;が該当します。(DWORDはx64だと32bitです)
ここまでわかると、この関数の大筋が見えてきます。
1 % 乱数 - 1を計算する際に、今回の場合だと乱数は0から2の範囲であるため、0が生成された場合、例外が発生します。
また、その後はStringの配列へアクセスするための長さチェックがあり、範囲外である場合は例外を投げています。
ここで、元の配列のデータ (kfun_kotlin_collections_copyOfUninitializedElements__at__kotlin_Array_0_0__kotlin_Int_kotlin_Int__0__kotlin_Any___kotlin_Array_0_0_ に渡されているデータ) を見てみると、長さが1の配列があることがわかります。
ここまでで、それぞれの対応表を作ってみます。
| インデックス | 経路 |
|---|---|
| 0 | ArithmeticException |
| 1 | ArrayIndexOutOfBoundsException |
| 2 | 配列にあるStringを使用 |
ここで疑問になるのは、どこで0あるいは1が入力された際の例外を捕捉しているということです。
Disassemblerでここの例外を投げているところを覗いてみると、try {...}の中の処理であることがわかります。
ChatGPTに聞いてみると、.eh_frame等にある情報を、IDAが変換してラベリングしてくれているようです。
ここのcatchの部分を見てみると、__unnamed_144といったデータ関連の処理がありそうで、ただの例外発生後のクリーンアップの処理ではなさそうです。
とはいえ、自分でアセンブリを読むのも気が引けるので、例外を投げている箇所をnopし、無理やりデコンパイラに出力させます。
今回はたまたま都合よく、例外を投げているところと例外補足の部分の機械語が連続しているため、きれいにフローを保てます。
そうすると、今回はきれいに先程の部分の処理を見ることができます。
elseが例外時の本来の挙動です。
この*(_DWORD *)(*(_QWORD *)(*v10 & 0xFFFFFFFFFFFFFFFCLL) + 92LL) が例外のタイプを判別しているという予想ができます。
ThrowArithmeticExceptionの実装を見てみます。
ポインターの8バイトに、type infoを入れていそうです。
この*(_DWORD *)(*(_QWORD *)(*v10 & 0xFFFFFFFFFFFFFFFCLL) + 92LL)が、type_info + 0x5Cと同等の処理をしていることを予測し、IDAで対象の箇所を見てみます。(0x5C = 92)
そうすると、0x80であることがわかります。
これは10進数で120になります。
よってこの条件式は、ArithmeticExceptionを判別する式になることがわかりました。
念のため、動的解析で型を確認します。
やり方としては、*(_DWORD *)(*(_QWORD *)(*v10 & 0xFFFFFFFFFFFFFFFCLL) + 92LL)の判定が通った際の処理にブレークポイントを置き、オブジェクトが代入されているr14の中身を見ます。
予想通り、r14にArithmeticExceptionが入っており、推測が正しいことがわかりました。
pwndbg> c
Continuing.
[New Thread 0x7ffff7bff6c0 (LWP 104)]
[New Thread 0x7ffff73fe6c0 (LWP 105)]
Thread 1 "chall" hit Breakpoint 1, 0x000000000025d0d5 in kfun:$genKey$lambda$3$lambda$2$lambda$1$lambda$0$FUNCTION_REFERENCE$2.invoke#internal ()
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
────────────────────────────────────────────────────[ LAST SIGNAL ]─────────────────────────────────────────────────────
Breakpoint hit at 0x25d0d5
...
*R13 0x7ffff7e3c5e0 —▸ 0x2c0c20 ◂— 0
*R14 0x7ffff6abe258 —▸ 0x27eea0 (kclass:kotlin[ArithmeticException]) ◂— 0x27eea0 (kclass:kotlin[ArithmeticException])
*R15 0x7ffff7e3c5e0 —▸ 0x2c0c20 ◂— 0
...
──────────────────────────────────────────[ DISASM / x86-64 / set emulate on ]──────────────────────────────────────────
► 0x25d0d5 <kfun:$genKey$lambda$3$lambda$2$lambda$1$lambda$0$FUNCTION_REFERENCE$2.invoke#internal+325> lea rax, [rip + 0x2ef14] RAX => 0x28bff0 (__unnamed_143) —▸ 0x27faf1 (kclass:kotlin[String]+1) ◂— 0x27fa
0x25d0dc <kfun:$genKey$lambda$3$lambda$2$lambda$1$lambda$0$FUNCTION_REFERENCE$2.invoke#internal+332> jmp kfun:$genKey$lambda$3$lambda$2$lambda$1$lambda$0$FUNCTION_REFERENCE$2.invoke#internal+217 <kfun:$genKey$lambda$3$lambda$2$lambda$1$lambda$0$FUNCTION_REFERENCE$2.invoke#internal+217>
...
ここまでわかると、この関数は与えられた0~2の数から対応する文字列を返す関数となっているようです。
抽出するにあたって
ここから、それぞれの文字列を抽出していきます。
手動で復元してもいいのですが、せっかくなのでgdbのスクリプト機能を利用したいと思います。
以下のスクリプトは、任意のStringオブジェクトの先頭アドレスから実際の中身をダンプしてくれるものです。
import gdb
import struct
inf = gdb.selected_inferior()
addr = 0x28BFF0
def dump_string(addr: int) -> str:
len_addr = addr + 0x8
len = struct.unpack("<Q", inf.read_memory(len_addr, 8))[0]
result = ""
start_addr = len_addr + 8
for i in range(len):
result += inf.read_memory(start_addr + i * 2, 2).tobytes().decode('utf-16')
return result
print(dump_string(addr))
0の場合
先程の画像より、result = &_unnamed_143; の _unnamed_143 が文字列になっています。
pwndbg> source string_dumper.py
OIPCYZVTKHDAUBEFRQSMJWNXGL
pwndbg>
1の場合
result = &_unnamed_144;の_unnamed_144が該当します。
pwndbg> source string_dumper.py
rwlihcuoebjxsptyfqvgzdnmka
pwndbg>
2の場合
要素が一個しかない配列なので、持ってくるのはとても簡単です。
kfun_kotlin_collections_copyOfUninitializedElements__at__kotlin_Array_0_0__kotlin_Int_kotlin_Int__0__kotlin_Any___kotlin_Array_0_0_ に渡されているデータから、Stringの要素へと飛びます。
pwndbg> source string_dumper.py
2049531678
pwndbg>
まとめ
0の場合: OIPCYZVTKHDAUBEFRQSMJWNXGL
1の場合: rwlihcuoebjxsptyfqvgzdnmka
2の場合: 2049531678
0〜2の整数から、対応する大文字・小文字・数字のいずれかのテーブルを選択して返す関数であることがわかりました。
暗号キー生成部分
やっと本筋に戻ってきました。
ついでに、Stringの型をIDA側にも定義しておきます。
文字列を受け取った直後、その文字列の長さから乱数を生成し、ランダムな一文字を取得しています。
そして、そのままCharオブジェクトとして初期化しているようです。
そして、ランダム文字のインデックス + 26 * 0~2の乱数から成る、整数オブジェクトを生成しています。
その後、生成したCharをループの外で作成された、Stringオブジェクトへappendしています。
そして、ランダム文字のインデックス + 26 * 0~2の乱数の整数オブジェクトを、unsigned byte (8bit整数) としてリストに追加しています。
ここで前章では不明だった、Pair<String, List<?>>がPair<String, List<UByte>>であることが判明しました。
暗号化後のファイル名には、この関数が返す文字列が利用されています。
一方で、暗号化処理では、この関数が返す List<UByte> が暗号鍵として用いられています。
つまり、暗号化後のファイル名に含まれる8文字は単なるランダムな名前ではなく、初期暗号鍵を復元するための情報を保持していることになります。
解析まとめ
ここまで、かなり長くなってしまったので、ここでわかったフローを改めてまとめ直します。
ファイル名 -> 暗号鍵 を変換するコード
ファイル名から一文字ずつ
- 文字テーブルの種類
- 文字のインデックス
を割り出し、元の計算式を利用してキーを復元します。
import typing
def file_name_to_encryption_key(file_name: str) -> list[int]:
UPPER_CASE_LETTERS = "OIPCYZVTKHDAUBEFRQSMJWNXGL"
LOWER_CASE_LETTERS = "rwlihcuoebjxsptyfqvgzdnmka"
NUMBER_LETTERS = "2049531678"
result = []
for c in file_name:
random = 0
index = 0
if c in UPPER_CASE_LETTERS:
random = 0
index = UPPER_CASE_LETTERS.index(c)
elif c in LOWER_CASE_LETTERS:
random = 1
index = LOWER_CASE_LETTERS.index(c)
elif c in NUMBER_LETTERS:
random = 2
index = NUMBER_LETTERS.index(c)
else:
raise ValueError(f"Invalid character in file name: {c}")
result.append(random * 26 + index)
return result
検証
試しに、実際に実行されて生成されたファイル名と、キーの内容を利用し、このスクリプトが正しく動いているのかを検証します。
実行した後、他の変数にペアの内容を代入しているところにそれぞれブレークポイントを置いて、中身を確認します。
ファイル名をダンプ
ファイル名を別のPairにコピーしているところにブレークポイントを置き、観察をします。
pwndbg> b *0x0025A906
Breakpoint 6 at 0x25a906
pwndbg> c
...
───────────────────────────────────────────────────────────────────[ LAST SIGNAL ]────────────────────────────────────────────────────────────────────
Breakpoint hit at 0x25a906
────────────────────────────────────────────────[ REGISTERS / show-flags off / show-compact-regs off ]────────────────────────────────────────────────
...
*RCX 0x7ffff6b7e4d8 —▸ 0x27faf0 (kclass:kotlin[String]) ◂— 0x27faf0 (kclass:kotlin[String])
...
─────────────────────────────────────────────────────────[ DISASM / x86-64 / set emulate on ]─────────────────────────────────────────────────────────
0x25a8f2 <kfun:#main(kotlin.Array<kotlin.String>){}+482> mov qword ptr [rsp + 0x1d8], rax
0x25a8fa <kfun:#main(kotlin.Array<kotlin.String>){}+490> mov rcx, qword ptr [rax + 8]
ファイル名はV02vil9Iになります。
pwndbg> hexdump 0x7ffff6b7e4d8
+0000 0x7ffff6b7e4d8 f0 fa 27 00 00 00 00 00 08 00 00 00 00 00 00 00 │..'.....│........│
+0010 0x7ffff6b7e4e8 56 00 30 00 32 00 76 00 69 00 6c 00 39 00 49 00 │V.0.2.v.│i.l.9.I.│
+0020 0x7ffff6b7e4f8 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 │........│........│
+0030 0x7ffff6b7e508 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 │........│........│
暗号キーをダンプ
同じように、配列の方を見ます。
pwndbg> b *0x0025A912
Breakpoint 7 at 0x25a912
pwndbg> c
...
Breakpoint hit at 0x25a912
...
*R14 0x7ffff6b7e370 —▸ 0x2810a0 (kclass:kotlin.collections[ArrayList]) ◂— 0x2810a0 (kclass:kotlin.collections[ArrayList])
...
0x25a8f2 <kfun:#main(kotlin.Array<kotlin.String>){}+482> mov qword ptr [rsp + 0x1d8], rax
0x25a8fa <kfun:#main(kotlin.Array<kotlin.String>){}+490> mov rcx, qword ptr [rax + 8]
配列の要素の部分を見てみると、UByteオブジェクトが並んでいます。
pwndbg> x/10gx 0x7ffff6b7e370+0x10
0x7ffff6b7e380: 0x00007ffff6a3e1c8 0x0000000000000000
0x7ffff6b7e390: 0x0000000000000000 0x000000000027ff70
0x7ffff6b7e3a0: 0x00007ffff6afe1c0 0x00007ffff6afe1d8
0x7ffff6b7e3b0: 0x00007ffff6bbe348 0x0000000000000000
0x7ffff6b7e3c0: 0x000000000027ff70 0x00007ffff6afe2c8
pwndbg> hexdump 0x00007ffff6a3e1c8
+0000 0x7ffff6a3e1c8 a0 e8 27 00 00 00 00 00 0a 00 00 00 00 00 00 00 │..'.....│........│
+0010 0x7ffff6a3e1d8 20 e2 af f6 ff 7f 00 00 50 e2 af f6 ff 7f 00 00 │........│P.......│
+0020 0x7ffff6a3e1e8 80 e2 af f6 ff 7f 00 00 28 e3 af f6 ff 7f 00 00 │........│(.......│
+0030 0x7ffff6a3e1f8 d0 e3 af f6 ff 7f 00 00 78 e4 af f6 ff 7f 00 00 │........│x.......│
pwndbg> tel 0x00007ffff6a3e1c8
00:0000│ 0x7ffff6a3e1c8 —▸ 0x27e8a0 (kclass:kotlin[Array]) ◂— 0x27e8a0 (kclass:kotlin[Array])
01:0008│ 0x7ffff6a3e1d0 ◂— 0xa /* '\n' */
02:0010│ 0x7ffff6a3e1d8 —▸ 0x7ffff6afe220 —▸ 0x2836b0 (kclass:kotlin[UByte]) ◂— 0x2836b0 (kclass:kotlin[UByte])
03:0018│ 0x7ffff6a3e1e0 —▸ 0x7ffff6afe250 —▸ 0x2836b0 (kclass:kotlin[UByte]) ◂— 0x2836b0 (kclass:kotlin[UByte])
04:0020│ 0x7ffff6a3e1e8 —▸ 0x7ffff6afe280 —▸ 0x2836b0 (kclass:kotlin[UByte]) ◂— 0x2836b0 (kclass:kotlin[UByte])
05:0028│ 0x7ffff6a3e1f0 —▸ 0x7ffff6afe328 —▸ 0x2836b0 (kclass:kotlin[UByte]) ◂— 0x2836b0 (kclass:kotlin[UByte])
06:0030│ 0x7ffff6a3e1f8 —▸ 0x7ffff6afe3d0 —▸ 0x2836b0 (kclass:kotlin[UByte]) ◂— 0x2836b0 (kclass:kotlin[UByte])
07:0038│ 0x7ffff6a3e200 —▸ 0x7ffff6afe478 —▸ 0x2836b0 (kclass:kotlin[UByte]) ◂— 0x2836b0 (kclass:kotlin[UByte])
pwndbg>
手動でやると、微妙に間違えそうなので、さっとスクリプトをかいてしまいます。
import gdb
import struct
inf = gdb.selected_inferior()
list_addr = 0x7ffff6b7e370
len_addr = list_addr + 0x8
len = struct.unpack("<I", inf.read_memory(len_addr, 4))[0]
print(f"List length: {len}")
items_ptr_addr = list_addr + 0x10
items_addr = struct.unpack("<Q", inf.read_memory(iter_ptr_addr, 8))[0]
print(f"Items address: {hex(items_addr)}")
result = []
for i in range(len):
items_content_addr = items_addr + 16
ubyte_obj_ptr = struct.unpack("<Q", inf.read_memory(items_content_addr + i * 8, 8))[0]
val_adr = ubyte_obj_ptr + 0x8
val = struct.unpack("<B", inf.read_memory(val_adr, 1))[0]
print(f"Item {i} value: {hex(val)}")
result.append(val)
print("Result:", result)
pwndbg> source list_peek.py
List length: 8
Items address: 0x7ffff6a3e1c8
Item 0 value: 0x6
Item 1 value: 0x35
Item 2 value: 0x34
Item 3 value: 0x2c
Item 4 value: 0x1d
Item 5 value: 0x1c
Item 6 value: 0x37
Item 7 value: 0x1
Result: [6, 53, 52, 44, 29, 28, 55, 1]
pwndbg>
スクリプトの結果と比較する
ここで、実際に書いたスクリプトが正しく動作するのかを見ます。
import typing
def file_name_to_encryption_key(file_name: str) -> list[int]:
...
print(file_name_to_encryption_key("V02vil9I"))
さきほどダンプした出力と比べても、正しく元に戻せていることがわかります。
PS C:\Ctf\FWE-CTF\NativeKotlian> python solver.py
[6, 53, 52, 44, 29, 28, 55, 1]
まとめ
この記事では、暗号キー生成関数を解析し、生成されるファイル名と暗号鍵の対応関係を確認しました。
最初は Pair<String, List<?>> のように見えていた戻り値を追っていくことで、この関数が Pair<String, List<UByte>> を返していることが分かりました。
1つ目の String は暗号化後のファイル名に利用され、2つ目の List<UByte> は暗号化処理で使われる初期鍵として利用されています。
また、キー文字列の生成では、大文字・小文字・数字の各テーブルから文字を選び、その文字の位置とテーブル種別をもとに UByte の値を生成していることが分かりました。
つまり、暗号化後のファイル名に含まれる8文字は単なるランダムな文字列ではなく、初期暗号鍵を復元するための情報を持っていることになります。
最終的に、ファイル名から初期暗号鍵を逆算するスクリプトを作成し、実行時に生成された List<UByte> の値と一致することを確認できました。
次の記事では、復元した初期鍵を使いながら、実際に8バイトブロックへ適用される encrypt 関数を解析していきます。

























