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

はじめに

前回の記事では、kfun__main 周辺を解析し、このプログラムが入力ファイルを8バイト単位で読み込み、生成したキーを使って暗号化していることを確認しました。

また、暗号化前の処理を追う中で、暗号キー生成関数の戻り値が Pair<String, List<?>> のような構造になっていることが分かりました。1つ目の値は出力ファイル名に使われる文字列、2つ目の値は暗号化処理に使われるキー配列であると考えられます。

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

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

暗号キー生成

ここからは厳密にそれぞれの関数を解析し、本格的に復号のプログラムを書いていきます。
手始めに、キー生成の部分を解析します。

最初は、0~2の範囲の乱数を生成した直後にラムダ関数っぽいのを呼んでいます。

image.png

キー文字列生成関数

先ほどと同じようにデバッガーで関数を特定すると、このような関数にジャンプしています。

image.png

今のところ、このあたりが気になるところです。

...
  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です)

image.png

ここまでわかると、この関数の大筋が見えてきます。
1 % 乱数 - 1を計算する際に、今回の場合だと乱数は0から2の範囲であるため、0が生成された場合、例外が発生します。

また、その後はStringの配列へアクセスするための長さチェックがあり、範囲外である場合は例外を投げています。

image.png

ここで、元の配列のデータ (kfun_kotlin_collections_copyOfUninitializedElements__at__kotlin_Array_0_0__kotlin_Int_kotlin_Int__0__kotlin_Any___kotlin_Array_0_0_ に渡されているデータ) を見てみると、長さが1の配列があることがわかります。

image.png

ここまでで、それぞれの対応表を作ってみます。

インデックス 経路
0 ArithmeticException
1 ArrayIndexOutOfBoundsException
2 配列にあるStringを使用

ここで疑問になるのは、どこで0あるいは1が入力された際の例外を捕捉しているということです。

Disassemblerでここの例外を投げているところを覗いてみると、try {...}の中の処理であることがわかります。
ChatGPTに聞いてみると、.eh_frame等にある情報を、IDAが変換してラベリングしてくれているようです。

image.png

image.png

ここのcatchの部分を見てみると、__unnamed_144といったデータ関連の処理がありそうで、ただの例外発生後のクリーンアップの処理ではなさそうです。
とはいえ、自分でアセンブリを読むのも気が引けるので、例外を投げている箇所をnopし、無理やりデコンパイラに出力させます。

image.png

今回はたまたま都合よく、例外を投げているところと例外補足の部分の機械語が連続しているため、きれいにフローを保てます。

image.png

そうすると、今回はきれいに先程の部分の処理を見ることができます。
elseが例外時の本来の挙動です。

image.png

この*(_DWORD *)(*(_QWORD *)(*v10 & 0xFFFFFFFFFFFFFFFCLL) + 92LL) が例外のタイプを判別しているという予想ができます。

ThrowArithmeticExceptionの実装を見てみます。
ポインターの8バイトに、type infoを入れていそうです。

image.png

この*(_DWORD *)(*(_QWORD *)(*v10 & 0xFFFFFFFFFFFFFFFCLL) + 92LL)が、type_info + 0x5Cと同等の処理をしていることを予測し、IDAで対象の箇所を見てみます。(0x5C = 92)

そうすると、0x80であることがわかります。
これは10進数で120になります。

image.png

よってこの条件式は、ArithmeticExceptionを判別する式になることがわかりました。

念のため、動的解析で型を確認します。
やり方としては、*(_DWORD *)(*(_QWORD *)(*v10 & 0xFFFFFFFFFFFFFFFCLL) + 92LL)の判定が通った際の処理にブレークポイントを置き、オブジェクトが代入されているr14の中身を見ます。

image.png

予想通り、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の数から対応する文字列を返す関数となっているようです。

image.png

抽出するにあたって

ここから、それぞれの文字列を抽出していきます。
手動で復元してもいいのですが、せっかくなので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 が文字列になっています。

image.png

pwndbg> source string_dumper.py
OIPCYZVTKHDAUBEFRQSMJWNXGL
pwndbg>

1の場合

result = &_unnamed_144;_unnamed_144が該当します。

image.png

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の要素へと飛びます。

image.png

image.png

pwndbg> source string_dumper.py
2049531678
pwndbg>

まとめ

0の場合: OIPCYZVTKHDAUBEFRQSMJWNXGL
1の場合: rwlihcuoebjxsptyfqvgzdnmka
2の場合: 2049531678

0〜2の整数から、対応する大文字・小文字・数字のいずれかのテーブルを選択して返す関数であることがわかりました。

暗号キー生成部分

やっと本筋に戻ってきました。
ついでに、Stringの型をIDA側にも定義しておきます。

image.png

文字列を受け取った直後、その文字列の長さから乱数を生成し、ランダムな一文字を取得しています。

image.png

そして、そのままCharオブジェクトとして初期化しているようです。

image.png

そして、ランダム文字のインデックス + 26 * 0~2の乱数から成る、整数オブジェクトを生成しています。

image.png

その後、生成したCharをループの外で作成された、Stringオブジェクトへappendしています。

image.png

image.png

そして、ランダム文字のインデックス + 26 * 0~2の乱数の整数オブジェクトを、unsigned byte (8bit整数) としてリストに追加しています。

ここで前章では不明だった、Pair<String, List<?>>Pair<String, List<UByte>>であることが判明しました。

image.png

暗号化後のファイル名には、この関数が返す文字列が利用されています。
一方で、暗号化処理では、この関数が返す 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

検証

試しに、実際に実行されて生成されたファイル名と、キーの内容を利用し、このスクリプトが正しく動いているのかを検証します。

実行した後、他の変数にペアの内容を代入しているところにそれぞれブレークポイントを置いて、中身を確認します。

image.png

ファイル名をダンプ

ファイル名を別の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 関数を解析していきます。

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

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?