はじめに
前回の記事では、暗号キー生成関数を解析し、ファイル名から初期暗号鍵を復元できることを確認しました。
この記事では、8バイトブロックに対して呼び出される encrypt 関数を解析します。
結論から言うと、この関数は再帰呼び出しと例外処理を制御フローとして利用しながら、kvar_map による置換、4bit rotate、要素のswapを行っていました。
以降では、IDAによる静的解析とpwndbgによる動的解析を使いながら、この処理を疑似Javaに落とし込み、復号スクリプトへつなげていきます。
このWriteupの他の記事はこちら↓
- FWE CTF - NativeKotlian Writeup (フロー解析)
- FWE CTF - NativeKotlian Writeup (暗号キー生成関数)
- FWE CTF - NativeKotlian Writeup (暗号化ロジック)
- FWE CTF - NativeKotlian Writeup(暗号化ロジック2・キー更新・復号)
暗号化関数の解析
いよいよ、encrypt関数を見ていきます。
関数としては、暗号化する配列と、対象の配列の位置を受け取るようになっているようです。
最初に、(index + 1) % 8で、
- 次のインデックス
- 8以上の場合は、0方向へ戻る位置
の計算をします。
その後、kvar_mapという配列から、暗号化する内容の配列[index] + index * indexをインデックスとして、その内容を代入しているようです。
これまでのフローを擬似Javaにすると、こんな感じになります。
int next = (index + 1) % 8;
array[index] = array[index] + array[next];
int mapIndex = array[index] + index * index;
array[index] = kvar_map[mapIndex];
kvar_mapの型について
キーを保管していた配列のように、kvar_mapを格納している変数にも同じArray構造体を利用しようとしたのですが、明らかにデコンパイル結果がおかしくなっています。(*(&map->length + 1)の部分)
少し調べると、blob_arrayはkotlin::ByteArray, kvar_mapはkotlin::Array周辺のクラスであることがわかります。
kotlin::ByteArrayは、Kotlinの標準ライブラリに含まれているようです。
ここまでわかったので、キー用の構造体とは別に、kvar_map用の構造体も作成します。
こんな型がKotlinの標準にあるのだなあというお話でした (Kotlin未経験)
ArithmeticExceptionの存在
kvar_map置き換え処理の直後に、少し気になるものがありました。
現在のindexが7だった場合、ArithmeticExceptionを発生させています。
そして、7以外の場合はindex + 1をし、再帰処理を走らせています。
普通に考えると、このままだと例外が発生して終わりのようです。
しかし実際には、この例外そのものが暗号化フローの一部として利用されていました。
しかし、Disassemblyをみてみると、先ほどと同じようにtry {...から始まっています。
ここで発生された例外も、この関数内でのcatch {...に拾われているようです。
実際に、catch(...) // owned by 25A338と記されているので、間違いなさそうです。
ここでもまた、例外を投げる命令と処理の命令が連続しているので、nopでゴリ押します。
そうすると、いつものように追加の処理が現れます。
ここで追加の一手間を加えます。
現在の状態だと、if (index == 7)が通り、ここの例外処理として飛んできている前提になっており、デコンパイラがindex = 7として認識してしまっているため、一部の結果が正確ではなくなってしまいます。
なので、cmp,jzの条件分岐を、jmpで無条件にしてしまいます。
IDAのデコンパイラはすごく賢いですが、たまに予期せぬこともしてくるので、気をつける必要があります。
少し混乱しますが、一旦個々の処理の解析を進めることにします。
catch句
前回の解析で、*(_DWORD *)(*(_QWORD *)(*v10 & 0xFFFFFFFFFFFFFFFCLL) + 92LL)が例外オブジェクトから、例外の型を取得することがわかっています。
つまり今回の式だと、例外タイプ値 - 125 <= 24と同義になります。
変形すると、例外タイプ値 <= 149になります。
ThrowArithmeticExceptionのタイプ値は、0x5C = 92だったので、この例外に当てはまると推測できます。
しかし、先ほどとは違い、特定のタイプに限らず範囲で条件分岐しています。
この条件分岐は特定の例外型だけを厳密に見ているというより、ある範囲の例外型をまとめて処理しているように見えます。
そのため、ここでは広い例外を受け取るcatch句に近い処理だと仮定して読み進めますが、後ほど動的解析で検証します。
やっていることはかなり単純のようで、
- 元のデータ配列の index 番目を取得
- 取得したものを4bit rotate leftする
- 配列の index 番目として設定 (indexは最初に渡された暗号化する位置)
のみのようです。
その後、例外オブジェクトを作成し、この段階ではスローしていないようです。
その変わり、exception_objを新しいExceptionで上書きしています。
finally句
ここへの処理は、例外のタイプを問わずにここへ到達しているので、これはfinally相当の処理だと推測ができます。
やっていることとしては、index番目とindex * 2 % 8番目の要素を入れ替えています。
そして、そのままexception_objを再スローしています。
フロー整備
ここまでで、ThrowArithmeticExceptionがスローされた場合とfinally相当の処理を解析しました。
改めて今までの状況を整備すると、
- 関数の冒頭では
- 自分の要素と次の要素で加算
-
kvar_mapによる置き換え
- indexが7の場合はArithmeticException発生
- 例外ハンドラによる種類別の暗号化処理
- catch句
- finally句
- 最後に再スロー
- 例外ハンドラによる種類別の暗号化処理
-
encrypt再帰呼び出し
という処理になっていきます。
再帰呼び出し中のencryptも同じ例外ハンドラに飛ぶので、このようなフローになります。
検証
難解かつ、推測の部分もあるので、動的解析で検証してみます。
ブレークポイントは3つ設置します
-
25A140: encrypt関数の先頭 -
25A380: catch句相当の処理の開始 -
25A3E9: finally句相当の処理の開始
最初は、encryptでブレークポイントがヒットします。
pwndbg> c
Continuing.
[New Thread 0x7ffff7bff6c0 (LWP 92)]
[New Thread 0x7ffff73fe6c0 (LWP 93)]
Thread 1 "chall" hit Breakpoint 1, 0x000000000025a140 in kfun:#encrypt(kotlin.UByteArray;kotlin.collections.List<kotlin.UByte>;kotlin.Int){} ()
再帰呼び出し
continueすると、再帰呼び出しによって、再びencryptへヒットします。
今は2回目の呼び出しなので、あと6回continueしてみます。
pwndbg> c
Continuing.
Thread 1 "chall" hit Breakpoint 1, 0x000000000025a140 in kfun:#encrypt(kotlin.UByteArray;kotlin.collections.List<kotlin.UByte>;kotlin.Int){} ()
例外処理
7回目のcontinueで、25A380へヒットしました。
これは先程の発見したcatch句に相当する箇所です。
pwndbg> c
Continuing.
Thread 1 "chall" hit Breakpoint 2, 0x000000000025a380 in kfun:#encrypt(kotlin.UByteArray;kotlin.collections.List<kotlin.UByte>;kotlin.Int){} ()
continueすると、finally句と予測した25A3E9へヒットします。
pwndbg> c
Continuing.
Thread 1 "chall" hit Breakpoint 3, 0x000000000025a3e9 in kfun:#encrypt(kotlin.UByteArray;kotlin.collections.List<kotlin.UByte>;kotlin.Int){} ()
もう一度、continueすると再びcatch句へ帰ってきます。
pwndbg> c
Continuing.
Thread 1 "chall" hit Breakpoint 2, 0x000000000025a380 in kfun:#encrypt(kotlin.UByteArray;kotlin.collections.List<kotlin.UByte>;kotlin.Int){} ()
高級言語に戻してみる
フローの推測がほぼ正しいことがわかったので、疑似Javaに戻してみます。
void encrypt(UByteArray array, int index) {
Exception ex = null;
try {
int next = (index + 1) % 8; // 8を超えた場合は先頭へ戻る
array[index] += array[next];
int kvarMapIndex = array[index] + index * index;
array[index] = kvarMap[kvarMapIndex];
if (index == 7) {
throw new ThrowArithmeticException();
}
encrypt(array, index + 1);
}
catch (Exception) {
array[index] = rol(array[index], 4);
ex = new Exception();
}
finally {
// index, index * 2 % 8をスワップ
int s = (index * 2) % 8;
int a = array[index];
int b = array[s];
array[index] = b;
array[s] = a;
throw ex;
}
}
復号化スクリプト作成
先ほど作成したフローチャートと、高級言語に戻した結果を参考にしつつ、この部分の復号スクリプトを作成します。
catch/finallyのプロセス
最初に行うべきはこの箇所です。
7,6,...,1,0と進んでいくので、復号する際は逆順にしてあげます。
def ror8(value: int, shift: int) -> int:
return ((value >> shift) | (value << (8 - shift))) & 0xFF
...
# inverse catch/finally process
for i in range(8):
# 25A3E9
to_swap = (i * 2) % 8
index_val = blob[i]
to_swap_val = blob[to_swap]
blob[i] = to_swap_val
blob[to_swap] = index_val
# 25A380
blob[i] = ror8(blob[i], 4)
隣の要素へ加算 & kvar_mapによる置き換えされたものを復元
こっちは、0,1,...,6,7の順番で行われるので、先ほどとは違い、7,6,...,1,0の順番で行う必要があります。
最初に、kvar_mapをダンプするスクリプトを作成します。
import gdb
import struct
inf = gdb.selected_inferior()
kvar_map_ptr = 0x2945F8
kvar_map = struct.unpack("<Q", inf.read_memory(kvar_map_ptr, 8))[0]
print(f"KvarMap address: {hex(kvar_map)}")
items_addr = struct.unpack("<Q", inf.read_memory(kvar_map + 0x10, 8))[0]
items_length = struct.unpack("<I", inf.read_memory(items_addr + 0x8, 4))[0]
print(f"Items length: {items_length}")
items_content_addr = items_addr + 0x10
result = []
for i in range(256): # 0 ~ 255
val_addr = struct.unpack("<Q", inf.read_memory(items_content_addr + i * 8, 8))[0]
val = struct.unpack("<I", inf.read_memory(val_addr + 8, 4))[0]
result.append(val)
print("Result:", result)
pwndbg> source kvar_map_dumper.py
KvarMap address: 0x7ffff6b7e050
Items length: 256
Result: [101, 163, 69, 35, 181, 201, 0, 113, 5, 150, 157, 141, 24, 217, 89, 6, 7, 111, 112, 124, 31, 109, 57, 133, 10, 204, 79, 238, 175, 19, 206, 47, 149, 90, 184, 215, 140, 96, 227, 36, 186, 230, 123, 228, 85, 44, 132, 11, 13, 58, 118, 60, 192, 14, 246, 244, 99, 179, 25, 41, 240, 84, 212, 129, 190, 236, 155, 80, 194, 49, 66, 116, 8, 139, 18, 62, 207, 151, 37, 243, 131, 185, 87, 130, 70, 28, 224, 162, 43, 223, 211, 226, 251, 254, 92, 56, 239, 137, 81, 177, 55, 233, 134, 208, 107, 158, 189, 205, 166, 95, 46, 174, 102, 106, 171, 74, 64, 218, 225, 219, 142, 98, 12, 76, 91, 145, 172, 248, 193, 94, 237, 221, 97, 231, 191, 199, 119, 195, 45, 234, 146, 161, 214, 159, 135, 105, 147, 115, 198, 72, 82, 42, 187, 86, 222, 21, 170, 178, 138, 104, 164, 180, 176, 252, 30, 67, 125, 4, 156, 241, 83, 59, 196, 154, 121, 3, 165, 65, 203, 182, 122, 75, 128, 114, 200, 39, 117, 34, 27, 232, 103, 148, 40, 202, 229, 255, 17, 110, 54, 108, 15, 250, 73, 23, 100, 126, 183, 38, 153, 26, 169, 197, 71, 1, 61, 51, 63, 33, 152, 143, 68, 88, 2, 160, 52, 9, 167, 253, 93, 213, 29, 235, 127, 173, 209, 247, 144, 50, 120, 78, 210, 22, 48, 249, 168, 77, 220, 20, 216, 16, 32, 188, 136, 245, 242, 53]
pwndbg>
そのままこっちの逆過程を行うコードも書きます。
kvar_map = [101, 163, 69, 35, 181, 201, ...]
...
# inverse addition and s-box process
for i in range(7, 0, -1):
# 25A214
'''
int kvarMapIndex = array[index] + index * index;
array[index] = kvarMap[kvarMapIndex];
'''
kvarMapIndex = kvar_map.index(blob[i])
blob[i] = kvarMapIndex - i * i
# 25A1F2
'''
*((_BYTE *)&blob_array->items + (int)index) += *((_BYTE *)&blob_array->items + next_index);// 配列の隣の値を足す
'''
a = blob[(i + 1) % 8]
blob[i] = (blob[i] - a) % 256
完成
完成すると、こうなります。
def decrypt(blob: list[int]):
# inverse catch/finally process
for i in range(8):
# 25A3E9
to_swap = (i * 2) % 8
index_val = blob[i]
to_swap_val = blob[to_swap]
blob[i] = to_swap_val
blob[to_swap] = index_val
# 25A380
blob[i] = rotr8(blob[i], 4)
# inverse addition and s-box process
for i in range(7, -1, -1):
# 25A214
'''
int kvarMapIndex = array[index] + index * index;
array[index] = kvarMap[kvarMapIndex];
'''
kvarMapIndex = kvar_map.index(blob[i])
blob[i] = (kvarMapIndex - i * i) & 0xFF
# 25A1F2
'''
*((_BYTE *)&blob_array->items + (int)index) += *((_BYTE *)&blob_array->items + next_index);// 配列の隣の値を足す
'''
a = blob[(i + 1) % 8]
blob[i] = (blob[i] - a) & 0xFF
検証
作成した復号化関数が正しいのかを検証します。
25C3D7にブレークポイントを置き、実行前の配列と、実行後の配列を取得します。
import gdb
import struct
inf = gdb.selected_inferior()
list_addr = 0x7fffffffd8b0
len_addr = list_addr + 0x8
len = struct.unpack("<I", inf.read_memory(len_addr, 4))[0]
print(f"List length: {len}")
items_addr = list_addr + 0x10
print(f"Items address: {hex(items_addr)}")
result = []
for i in range(len):
val = struct.unpack("<B", inf.read_memory(items_addr + i, 1))[0]
print(f"Element {i} value: {hex(val)}")
result.append(val)
print("Result:", result)
暗号化前
pwndbg> context
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
...
*RDI 0x7fffffffd8b0 —▸ 0x27e9c3 (kclass:kotlin[ByteArray]+3) ◂— 0
...
► 0x25c3d7 <kfun:#main(kotlin.Array<kotlin.String>){}+7367> call kfun:#encrypt(kotlin.UByteArray;kotlin.collections.List<kotlin.UByte>;kotlin.Int){} <kfun:#encrypt(kotlin.UByteArray;kotlin.collections.List<kotlin.UByte>;kotlin.Int){}>
pwndbg> source list_peek.py
List length: 8
Items address: 0x7fffffffd8c0
Element 0 value: 0x48
Element 1 value: 0x49
Element 2 value: 0x21
Element 3 value: 0x3
Element 4 value: 0x3
Element 5 value: 0x3
Element 6 value: 0x3
Element 7 value: 0x3
Result: [72, 73, 33, 3, 3, 3, 3, 3]
pwndbg>
暗号化後
pwndbg> n
...
*RDI 0x2c0d80 —▸ 0x2a7210 ◂— 0
...
b+ 0x25c3d7 <kfun:#main(kotlin.Array<kotlin.String>){}+7367> call kfun:#encrypt(kotlin.UByteArray;kotlin.collections.List<kotlin.UByte>;kotlin.Int){} <kfun:#encrypt(kotlin.UByteArray;kotlin.collections.List<kotlin.UByte>;kotlin.Int){}>
► 0x25c3dc <kfun:#main(kotlin.Array<kotlin.String>){}+7372> mov rdi, rbx RDI => 0x7fffffffd8b0 —▸ 0x27e9c3 (kclass:kotlin[ByteArray]+3) ◂— 0
0x25c3df <kfun:#main(kotlin.Array<kotlin.String>){}+7375> xor esi, esi ESI => 0
0x25c3e1 <kfun:#main(kotlin.Array<kotlin.String>){}+7377> call kfun:#encrypt(kotlin.UByteArray;kotlin.collections.List<kotlin.UByte>;kotlin.Int){} <kfun:#encrypt(kotlin.UByteArray;kotlin.collections.List<kotlin.UByte>;kotlin.Int){}>
...
pwndbg> source list_peek.py
List length: 8
Items address: 0x7fffffffd8c0
Element 0 value: 0x80
Element 1 value: 0xf5
Element 2 value: 0x4a
Element 3 value: 0x23
Element 4 value: 0xe6
Element 5 value: 0x1d
Element 6 value: 0x5c
Element 7 value: 0x32
Result: [128, 245, 74, 35, 230, 29, 92, 50]
pwndbg>
比較
[128, 245, 74, 35, 230, 29, 92, 50]を入力とし、出力が[72, 73, 33, 3, 3, 3, 3, 3]になるかどうかを見ます。
しかし、うまく復号できません。
test = [128, 245, 74, 35, 230, 29, 92, 50]
decrypt(test)
print("decrypt", test)
PS C:\Ctf\FWE-CTF\NativeKotlian> python solver.py
decrypt [146, 107, 52, 174, 28, 215, 57, 144]
PS C:\Ctf\FWE-CTF\NativeKotlian>
ここでもう一度、暗号化後の出力を見てみると、先ほどと違う出力になっています。
このencrypt関数では、
- 元の配列データ
- 開始インデックス
しか受け取っておらず、元の配列データが変わらない限り、結果は一緒のはずです。
例外フローの続編
ここで先ほどのフローを思い出すと、この関数は最後の例外処理が終了すると、例外を外部へ伝搬するのでした。
encrypt呼び出しを見てみると、try {...から始まっており、どこかしらで例外をcatchしています。
どうやら、この辺が原因のようです。
ここに対応するcatch句を探すと、25C73Cに存在することがわかります。
例外タイプを判定し、もし合致する場合はtpyrcneを呼んでいます。
*(_DWORD *)(*(_QWORD *)(*v10 & 0xFFFFFFFFFFFFFFFCLL) + 92LL)は例外タイプ値の取得です。
例外タイプ値と0x18を比較し、例外タイプ値のほうが数値が大きい場合、loc_25C983へ飛んでいます。
; catch(...) // owned by 25C3CA
mov rcx, [rsp+348h+var_348]
mov rcx, [rcx]
lea rdx, [rsp+348h+var_340]
mov [rcx+0D0h], rdx
mov rdi, rax ; void *
call __cxa_begin_catch
mov rax, [rax+8]
mov r15, [rax]
mov qword ptr [rsp+348h+file_name_string_1], r15
call __cxa_end_catch
mov rax, [r15]
and rax, 0FFFFFFFFFFFFFFFCh
mov rax, [rax]
mov eax, [rax+5Ch]
add eax, 0FFFFFF83h
cmp eax, 18h
ja loc_25C983
; try {
lea rbx, [rsp+348h+blob_array]
mov rdi, rbx
mov rsi, r14
mov r12, 300000000h
call kfun__tpyrcne_kotlin_UByteArray_kotlin_collections_List_kotlin_UByte____
; } // starts at 25C781
動的解析で、今回の場合がこの条件に当てはまるかを見てみましょう。
ja命令でStepをすると、条件を満たしていないため、そのままtpyrcneのcallに入ります。
pwndbg> b *0x25C77B
Breakpoint 19 at 0x25c77b
pwndbg> c
Continuing.
...
*R15 0x7ffff6abe610 —▸ 0x27efe0 (kclass:kotlin[Exception]) ◂— 0x27efe0 (kclass:kotlin[Exception])
...
► 0x25c77b <kfun:#main(kotlin.Array<kotlin.String>){}+8299> ✘ ja kfun:#main(kotlin.Array<kotlin.String>){}+8819 <kfun:#main(kotlin.Array<kotlin.String>){}+8819>
0x25c781 <kfun:#main(kotlin.Array<kotlin.String>){}+8305> lea rbx, [rsp + 0xc0] RBX => 0x7fffffffd8b0 —▸ 0x27e9c3 (kclass:kotlin[ByteArray]+3) ◂— 0
...
pwndbg> ni
0x000000000025c781 in kfun:#main(kotlin.Array<kotlin.String>){} ()
...
R15 0x7ffff6abe610 —▸ 0x27efe0 (kclass:kotlin[Exception]) ◂— 0x27efe0 (kclass:kotlin[Exception])
...
b+ 0x25c77b <kfun:#main(kotlin.Array<kotlin.String>){}+8299> ✘ ja kfun:#main(kotlin.Array<kotlin.String>){}+8819 <kfun:#main(kotlin.Array<kotlin.String>){}+8819>
► 0x25c781 <kfun:#main(kotlin.Array<kotlin.String>){}+8305> lea rbx, [rsp + 0xc0] RBX ...
pwndbg>
新しい要素を見つけましたが、今は先ほどの関数の復号化がうまく行っているかだけを検証します。
仕方がないので、encrypt関数の最後の命令まですすめ、その時点でのデータを抽出します。
pwndbg> c
...
pwndbg> c
...
pwndbg> c
...
pwndbg> c
...
pwndbg> c
...
pwndbg> c
...
pwndbg> c
...
pwndbg> c
Continuing.
...
► 0x25a436 <kfun:#encrypt(kotlin.UByteArray;kotlin.collections.List<kotlin.UByte>;kotlin.Int){}+758> call ThrowException <ThrowException>
rdi: 0x7ffff6abe5d8 —▸ 0x27efe0 (kclass:kotlin[Exception]) ◂— 0x27efe0 (kclass:kotlin[Exception])
rsi: 0
rdx: 0xb2
rcx: 0x7fffffffd770 —▸ 0x7fffffffd7f8 —▸ 0x7fffffffd8f0 —▸ 0x7fffffffdb40 —▸ 0x7fffffffdb60 ◂— ...
...
pwndbg> source list_peek.py
List length: 8
Items address: 0x7fffffffd8c0
Element 0 value: 0xb2
Element 1 value: 0x69
Element 2 value: 0xdc
Element 3 value: 0x39
Element 4 value: 0x2f
Element 5 value: 0xba
Element 6 value: 0x60
Element 7 value: 0x7b
Result: [178, 105, 220, 57, 47, 186, 96, 123]
普通に動いていそうです。
test = [178, 105, 220, 57, 47, 186, 96, 123]
decrypt(test)
print("decrypt", test)
PS C:\Ctf\FWE-CTF\NativeKotlian> python solver.py
decrypt [72, 73, 33, 3, 3, 3, 3, 3]
まとめ
この記事では、8バイトブロックに対して呼び出される encrypt 関数を解析しました。
最初は単純な暗号化関数に見えましたが、実際には再帰呼び出しと例外処理を制御フローとして利用しており、IDAのデコンパイル結果だけでは全体像を把握しにくい構造になっていました。
解析の結果、encrypt 関数では主に以下の処理が行われていることが分かりました。
- 隣の要素を加算する
-
kvar_mapを用いて値を置き換える -
ArithmeticExceptionを発生させ、catch句側で4bit rotate leftを行う - finally句相当の処理で、
index番目と(index * 2) % 8番目の要素をswapする
また、例外処理の流れが分かりにくかったため、IDAのデコンパイル結果だけに頼らず、Disassemblyの確認やpwndbgでのブレークポイントを使って、catch句・finally句相当の処理がどの順番で実行されているかを確認しました。
最終的には、encrypt 関数の処理を疑似Javaとして整理し、復号に必要な逆変換の一部をPythonで実装できました。
次の記事では、encrypt 関数の後に呼び出される tpyrcne 関数とキー更新処理を解析し、最終的な復号スクリプトの完成を目指します。



















