0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

FWE CTF - NativeKotlian Writeup (暗号化ロジック)

0
Last updated at Posted at 2026-05-09

はじめに

前回の記事では、暗号キー生成関数を解析し、ファイル名から初期暗号鍵を復元できることを確認しました。

この記事では、8バイトブロックに対して呼び出される encrypt 関数を解析します。
結論から言うと、この関数は再帰呼び出しと例外処理を制御フローとして利用しながら、kvar_map による置換、4bit rotate、要素のswapを行っていました。

以降では、IDAによる静的解析とpwndbgによる動的解析を使いながら、この処理を疑似Javaに落とし込み、復号スクリプトへつなげていきます。

このWriteupの他の記事はこちら↓

暗号化関数の解析

いよいよ、encrypt関数を見ていきます。
関数としては、暗号化する配列と、対象の配列の位置を受け取るようになっているようです。

image.png

最初に、(index + 1) % 8で、

  • 次のインデックス
  • 8以上の場合は、0方向へ戻る位置

の計算をします。

image.png

その後、kvar_mapという配列から、暗号化する内容の配列[index] + index * indexをインデックスとして、その内容を代入しているようです。

image.png

これまでのフローを擬似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)の部分)

image.png

少し調べると、blob_arrayはkotlin::ByteArray, kvar_mapはkotlin::Array周辺のクラスであることがわかります。

image.png

image.png

kotlin::ByteArrayは、Kotlinの標準ライブラリに含まれているようです。

ここまでわかったので、キー用の構造体とは別に、kvar_map用の構造体も作成します。

image.png

image.png

こんな型がKotlinの標準にあるのだなあというお話でした (Kotlin未経験)

ArithmeticExceptionの存在

kvar_map置き換え処理の直後に、少し気になるものがありました。
現在のindexが7だった場合、ArithmeticExceptionを発生させています。

そして、7以外の場合はindex + 1をし、再帰処理を走らせています。

普通に考えると、このままだと例外が発生して終わりのようです。
しかし実際には、この例外そのものが暗号化フローの一部として利用されていました。

image.png

しかし、Disassemblyをみてみると、先ほどと同じようにtry {...から始まっています。
ここで発生された例外も、この関数内でのcatch {...に拾われているようです。

実際に、catch(...) // owned by 25A338と記されているので、間違いなさそうです。

image.png

ここでもまた、例外を投げる命令と処理の命令が連続しているので、nopでゴリ押します。
そうすると、いつものように追加の処理が現れます。

image.png

ここで追加の一手間を加えます。
現在の状態だと、if (index == 7)が通り、ここの例外処理として飛んできている前提になっており、デコンパイラがindex = 7として認識してしまっているため、一部の結果が正確ではなくなってしまいます。

image.png

なので、cmp,jzの条件分岐を、jmpで無条件にしてしまいます。

image.png

image.png

IDAのデコンパイラはすごく賢いですが、たまに予期せぬこともしてくるので、気をつける必要があります。

image.png

少し混乱しますが、一旦個々の処理の解析を進めることにします。

catch句

前回の解析で、*(_DWORD *)(*(_QWORD *)(*v10 & 0xFFFFFFFFFFFFFFFCLL) + 92LL)が例外オブジェクトから、例外の型を取得することがわかっています。

つまり今回の式だと、例外タイプ値 - 125 <= 24と同義になります。
変形すると、例外タイプ値 <= 149になります。

ThrowArithmeticExceptionのタイプ値は、0x5C = 92だったので、この例外に当てはまると推測できます。
しかし、先ほどとは違い、特定のタイプに限らず範囲で条件分岐しています。

この条件分岐は特定の例外型だけを厳密に見ているというより、ある範囲の例外型をまとめて処理しているように見えます。
そのため、ここでは広い例外を受け取るcatch句に近い処理だと仮定して読み進めますが、後ほど動的解析で検証します。

image.png

やっていることはかなり単純のようで、

  • 元のデータ配列の index 番目を取得
  • 取得したものを4bit rotate leftする
  • 配列の index 番目として設定 (indexは最初に渡された暗号化する位置)

のみのようです。

image.png

その後、例外オブジェクトを作成し、この段階ではスローしていないようです。
その変わり、exception_objを新しいExceptionで上書きしています。

finally句

ここへの処理は、例外のタイプを問わずにここへ到達しているので、これはfinally相当の処理だと推測ができます。

やっていることとしては、index番目とindex * 2 % 8番目の要素を入れ替えています。
そして、そのままexception_objを再スローしています。

image.png

フロー整備

ここまでで、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しています。

どうやら、この辺が原因のようです。

image.png

ここに対応するcatch句を探すと、25C73Cに存在することがわかります。
例外タイプを判定し、もし合致する場合はtpyrcneを呼んでいます。

image.png

*(_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 関数とキー更新処理を解析し、最終的な復号スクリプトの完成を目指します。

FWE CTF - NativeKotlian Writeup(暗号化ロジック2・キー更新・復号)

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?