はじめに
2025/8に開催された、Full Weak Engineer CTFの問題のWriteupです。
以前にもWriteupを作成したのですが、あまりにもぐしゃぐしゃだったため、再度書くことにしました💦
ついでに、Upsolveも兼ねての記事です!
一つの記事にするには、あまりにも文やメディアの量が多すぎたので、4つの記事にわけて公開しています ↓
- FWE CTF - NativeKotlian Writeup (フロー解析)
- FWE CTF - NativeKotlian Writeup (暗号キー生成関数)
- FWE CTF - NativeKotlian Writeup (暗号化ロジック)
- FWE CTF - NativeKotlian Writeup(暗号化ロジック2・キー更新・復号)
問題概要 (公式Repoから引用)
| ジャンル | 問題名 | 作問者 | タグ | 最終スコア | Solve数 |
|---|---|---|---|---|---|
| Rev | NativeKotlian | こーふ | Hard | 497 | 2 |
内容としては、Kotlin/Nativeでビルドされた、ネイティブで動くKotlinアプリケーションを解析する問題です。
添付ファイルには、実行可能ELF形式 (x86-64) のファイルが含まれています。
コマンドラインから与えられたファイルを暗号化して保存します。
babar@mypc:/mnt/c/Ctf/FWE-CTF/NativeKotlian$ ./chall
Usage: chall <input>
babar@mypc:/mnt/c/Ctf/FWE-CTF/NativeKotlian$ ./chall data.txt
そして、この問題は、暗号化されたファイルのみが与えられ、それを復号化する問題です。
フロー解析
エントリーポイント付近をしばらく漁ると、kfun__mainから始まるメイン関数を見つけることができます。
しかし、あまりにも大規模な関数となってしまっているので、最初にどのようなフローでこのアプリケーションが処理を行うかの大筋の予測を行います。
引数チェック・使用方法出力
最初の方に出てくる、if (*(args + 8) != 1)で始まる処理を見ます。
args + 8 には、int32で引数の数が入っています (0x7ffff6bbe028がargsへのアドレス)
つまり、./chall 引数という使用方法を要求していることがわかります。
そして、もし引数の数が1個ではない場合は、/proc/self/cmdlineからBufferedSourceを作成し、読み取っています。
そして読み取ったものを加工し、最後にUsageを出力します。
暗号化キー生成
続いて、暗号化キー生成のフローを確認します。
上の方ではすでに、ファイルパスを引数から取得する処理などをすでに行っていますが、一旦、キー生成の関数のみに着目して進めます。
以下の部分が関数呼び出しになっているように見えます。
v246[0] = (**(*(*(*genKey_lambda & 0xFFFFFFFFFFFFFFFCLL) + 64LL)
+ 16LL * (*(*(*genKey_lambda & 0xFFFFFFFFFFFFFFFCLL) + 60LL) & 0x79)
+ 8))(
genKey_lambda,
v246);
デバッガーで該当箇所にブレークポイントを置き、Step Instructionをすると、kfun__genKey_lambda_3_lambda_2_FUNCTION_REFERENCE_4_invoke_internalへcallしていることがわかります。
────────────────────────────────────────────────────────[ LAST SIGNAL ]────────────────────────────────────────────────────────
Breakpoint hit at 0x25a8f0
...
─────────────────────────────────────────────[ DISASM / x86-64 / set emulate on ]──────────────────────────────────────────────
► 0x25a8f0 <kfun:#main(kotlin.Array<kotlin.String>){}+480> call qword ptr [rcx]
...
pwndbg> si
0x000000000025d110 in kfun:$genKey$lambda$3$lambda$2$FUNCTION_REFERENCE$4.invoke#internal ()
─────────────────────────────────────────────[ DISASM / x86-64 / set emulate on ]──────────────────────────────────────────────
► 0x25d110 <kfun:$genKey$lambda$3$lambda$2$FUNCTION_REFERENCE$4.invoke#internal> push rbp
0x25d111 <kfun:$genKey$lambda$3$lambda$2$FUNCTION_REFERENCE$4.invoke#internal+1> push r15
0x25d113 <kfun:$genKey$lambda$3$lambda$2$FUNCTION_REFERENCE$4.invoke#internal+3> push r14
0x25d115 <kfun:$genKey$lambda$3$lambda$2$FUNCTION_REFERENCE$4.invoke#internal+5> push r13
0x25d117 <kfun:$genKey$lambda$3$lambda$2$FUNCTION_REFERENCE$4.invoke#internal+7> push r12
...
pwndbg>
見つかった関数の最後のあたりを見ると、何かのPairを戻り値としているようです。
v54, v56をxrefし、代入されている周辺を見てみると、最初の方で、pair相当(128bit)の変数に空の文字列と、配列を代入していることがわかります。
このことから、genKeyの戻り値が Pair<String, List<?>> のようなものであることが見えます。
今回の目的はフローを知ることなので、厳密な解析は一旦後回しにします。
出力先パス構築
先ほど一旦スルーした、パス取得後の処理を追います。
パスを引数から取得し、String化していることが流れでわかります。
その直後に、String化したものをsplitしている箇所があります。
しかし、何を区切り (delimiter) としているかがわかりにくいため、splitの箇所でブレークポイントを設定し、動的解析を行います。
pwndbg> context
...
*RSI 0x289c70 (__unnamed_62) —▸ 0x27faf1 (kclass:kotlin[String]+1) ◂— 0x27fa
...
─────────────────────────────────────────────[ DISASM / x86-64 / set emulate on ]──────────────────────────────────────────────
► 0x25aa3d <kfun:#main(kotlin.Array<kotlin.String>){}+813> call kfun:kotlin.text.split#internal <kfun:kotlin.text.split#internal>
rdi: 0x7ffff6b7e438 —▸ 0x27faf0 (kclass:kotlin[String]) ◂— 0x27faf0 (kclass:kotlin[String])
rsi: 0x289c70 (__unnamed_62) —▸ 0x27faf1 (kclass:kotlin[String]+1) ◂— 0x27fa
rdx: 2
rcx: 0x7fffffffd960 ◂— 0
...
pwndbg> hexdump 0x289c70
+0000 0x289c70 f1 fa 27 00 00 00 00 00 01 00 00 00 00 00 00 00 │..'.....│........│
+0010 0x289c80 2e 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 │........│........│
+0020 0x289c90 f1 fa 27 00 00 00 00 00 0f 00 00 00 00 00 00 00 │..'.....│........│
+0030 0x289ca0 41 00 72 00 72 00 61 00 79 00 20 00 69 00 73 00 │A.r.r.a.│y...i.s.│
pwndbg>
出力されたアドレスをIDAで見てみると、.単体の文字であることがわかりました。
splitをしたあと、配列から特定の要素を抽出し、そこから新しいStringのインスタンスを作成している処理が見つかります。
同じように、string_from_array = var_140;にブレークポイントを置き、何に該当するStringなのかを特定します。
そうすると、拡張子を格納している文字列であることがわかります。
...
*R12 0x7ffff6bbe688 —▸ 0x27faf0 (kclass:kotlin[String]) ◂— 0x27faf0 (kclass:kotlin[String])
...
─────────────────────────────────────────────[ DISASM / x86-64 / set emulate on ]──────────────────────────────────────────────
► 0x25b558 <kfun:#main(kotlin.Array<kotlin.String>){}+3656> mov qword ptr [rsp + 0x208], r12 [0x7fffffffd968] <= 0x7ffff6bbe688 —▸ 0x27faf0 (kclass:kotlin[String]) ◂— 0x27faf0
...
pwndbg> hexdump 0x7ffff6bbe688
+0000 0x7ffff6bbe688 f0 fa 27 00 00 00 00 00 03 00 00 00 00 00 00 00 │..'.....│........│
+0010 0x7ffff6bbe698 74 00 78 00 74 00 00 00 00 00 00 00 00 00 00 00 │t.x.t...│........│
+0020 0x7ffff6bbe6a8 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 │........│........│
+0030 0x7ffff6bbe6b8 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 │........│........│
pwndbg>
もう少し整備すると、こんな感じになりそうです。
そうすると、これまでの変数を利用し、出力するファイル名をビルドする処理にたどり着くことができます。
file_name_array_2 = kotlin::alloc::CustomAllocator::Allocate(*(*v8 + 224LL), 6u);
*file_name_array_2 = 0;
*(file_name_array_2 + 8) = &kclass_kotlin_CharArray;
...
memcpy((file_name_array_1 + 2LL * v220 + 16), key_string_1 + 16, 2 * key_string_len);// 生成したkey文字列をコピー
...
*(file_name_array_1 + 2LL * v220 + 16) = '.'; // .を追加
...
memcpy((file_name_array_1 + 2LL * v220 + 16), extension_string + 2, 2 * v138);// 拡張子をコピー
...
*(file_name_array_1 + 2LL * v220 + 16) = *&encryted_str;// .encryptedを末尾に追加
*(v141 + 2 * v140 + 32) = 100;
実際に中身を見てみても、出力されるファイル名が構築されていることがわかります。
pwndbg> hexdump 0x7ffff69be038
+0000 0x7ffff69be038 36 00 77 00 58 00 6a 00 4d 00 46 00 51 00 6d 00 │6.w.X.j.│M.F.Q.m.│
+0010 0x7ffff69be048 2e 00 74 00 78 00 74 00 2e 00 65 00 6e 00 63 00 │..t.x.t.│..e.n.c.│
+0020 0x7ffff69be058 72 00 79 00 70 00 74 00 65 00 64 00 00 00 00 00 │r.y.p.t.│e.d.....│
+0030 0x7ffff69be068 01 00 00 00 00 00 00 00 f0 fa 27 00 00 00 00 00 │........│..'.....│
pwndbg>
暗号化前準備
ここから、いよいよ暗号化周辺の処理になります。
最初に元ファイルからの読み取り準備及び、出力先ファイルへのSink作成を行っています。
その後ループへ入り、ファイルの中身を読み取った後に、更に8バイトずつ処理していることがわかります。
また、読んだバイト数が8バイト未満の場合、読んだデータを8バイトにパディングしています。
なおパディングをする際は、読んだバイト数をそのまま入れる値としているようです。
var_278について
file_content_bufferとは別にvar_278 変数にてパディングを設定しており、 Xrefをみても代入箇所が見当たらず、何の変数なのかが不明瞭でした。
そこで、該当のスタックアドレスにwatchpointを設定し、どこから書き込んでいるかを調査します。
関数のはじめ、つまりスタック確保後にブレークポイントを置きます。
pwndbg> b *0x25A721
Breakpoint 10 at 0x25a721
そして、var_278が格納されている、$rsp + 0xd0にwatchpointを置くために、rspのアドレスを取得します。
────────────────────────────────────────────────────────────────────────[ LAST SIGNAL ]─────────────────────────────────────────────────────────────────────────
Breakpoint hit at 0x25a721
─────────────────────────────────────────────────────[ REGISTERS / show-flags off / show-compact-regs off ]─────────────────────────────────────────────────────
...
*RSP 0x7fffffffd7f0 ◂— 0
...
──────────────────────────────────────────────────────────────[ DISASM / x86-64 / set emulate on ]──────────────────────────────────────────────────────────────
0x25a713 <kfun:#main(kotlin.Array<kotlin.String>){}+3> push r14
0x25a715 <kfun:#main(kotlin.Array<kotlin.String>){}+5> push r13
0x25a717 <kfun:#main(kotlin.Array<kotlin.String>){}+7> push r12
0x25a719 <kfun:#main(kotlin.Array<kotlin.String>){}+9> push rbx
0x25a71a <kfun:#main(kotlin.Array<kotlin.String>){}+10> sub rsp, 0x318
► 0x25a721 <kfun:#main(kotlin.Array<kotlin.String>){}+17> mov rbx, rdi RBX => 0x7ffff6bbe028 —▸ 0x27e8a0 (kclass:kotlin[Array]) ◂— 0x27e8a0
そのままwatchpointを設置します。
pwndbg> watch *0x7FFFFFFFD8C0
Hardware watchpoint 17: *0x7FFFFFFFD8C0
そうすると、アクセスしている命令でブレークします。
pwndbg> c
Continuing.
Downloading source file ./string/../sysdeps/x86_64/multiarch/memmove-vec-unaligned-erms.S
Download failed: Invalid argument. Continuing without source file ./string/../sysdeps/x86_64/multiarch/memmove-vec-unaligned-erms.S.
Thread 1 "chall" hit Hardware watchpoint 16: *(0x7fffffffd7f0 + 0xD0)
Old value = 0
New value = 2181376
Thread 1 "chall" hit Hardware watchpoint 17: *0x7FFFFFFFD8C0
Old value = 0
New value = 2181376
__memcpy_avx_unaligned_erms () at ../sysdeps/x86_64/multiarch/memmove-vec-unaligned-erms.S:323
⚠️ warning: 323 ../sysdeps/x86_64/multiarch/memmove-vec-unaligned-erms.S: No such file or directory
Downloading source file ./string/../sysdeps/x86_64/multiarch/memmove-vec-unaligned-erms.S
Download failed: Invalid argument. Continuing without source file ./string/../sysdeps/x86_64/multiarch/memmove-vec-unaligned-erms.S.
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
─────────────────────────────────────────────────────────────────────────────────────────────────[ LAST SIGNAL ]─────────────────────────────────────────────────────────────────────────────────────────────────
Breakpoint hit at 0x7ffff7d88ad2
─────────────────────────────────────────────────────────────────────────────[ REGISTERS / show-flags off / show-compact-regs off ]──────────────────────────────────────────────────────────────────────────────
*RAX 0x7fffffffd8c0 —▸ 0x214900 ◂— 'd::istream'
*RBX 0x7ffff7e3c5e0 —▸ 0x2c0c20 ◂— 0
*RCX 0x7ffff6b66e48 ◂— 0
*RDX 3
*RDI 0x7fffffffd8c0 —▸ 0x214900 ◂— 'd::istream'
*RSI 0x2149
*R8 0x800000000
*R9 0x210
*R10 0x7ffff7c11c68 ◂— 0x11002200002fe2
*R11 0x7ffff7c8e720 (feof) ◂— endbr64
*R12 0
*R13 0x7ffff6abe3a8 —▸ 0x2839e0 (kclass:okio[Segment]) ◂— 0x2839e0 (kclass:okio[Segment])
*R14 3
*R15 0x7fffffffd8b0 —▸ 0x27e9c3 (kclass:kotlin[ByteArray]+3) ◂— 0
*RBP 3
*RSP 0x7fffffffd738 —▸ 0x254c0f (kfun:okio.Buffer#read(kotlin.ByteArray;kotlin.Int;kotlin.Int){}kotlin.Int+287) ◂— mov qword ptr [rsp + 0x20], r15
*RIP 0x7ffff7d88ad2 (__memmove_avx_unaligned_erms+82) ◂— mov byte ptr [rdi], cl
──────────────────────────────────────────────────────────────────────────────────────[ DISASM / x86-64 / set emulate on ]───────────────────────────────────────────────────────────────────────────────────────
► 0x7ffff7d88ad2 <__memmove_avx_unaligned_erms+82> mov byte ptr [rdi], cl [0x7fffffffd8c0] <= 0x48
0x7ffff7d88ad4 <__memmove_avx_unaligned_erms+84> ret <kfun:okio.Buffer#read(kotlin.ByteArray;kotlin.Int;kotlin.Int){}kotlin.Int+287>
↓
0x254c0f <kfun:okio.Buffer#read(kotlin.ByteArray;kotlin.Int;kotlin.Int){}kotlin.Int+287> mov qword ptr [rsp + 0x20], r15 [0x7fffffffd760] <= 0x7fffffffd8b0 —▸ 0x27e9c3 (kclass:kotlin[ByteArray]+3) ◂— 0
0x254c14 <kfun:okio.Buffer#read(kotlin.ByteArray;kotlin.Int;kotlin.Int){}kotlin.Int+292> add dword ptr [r13 + 0x20], r14d [0x7ffff6abe3c8] <= 3 (0 + 3)
0x254c18 <kfun:okio.Buffer#read(kotlin.ByteArray;kotlin.Int;kotlin.Int){}kotlin.Int+296> movsxd rax, r14d RAX => 3
0x254c1b <kfun:okio.Buffer#read(kotlin.ByteArray;kotlin.Int;kotlin.Int){}kotlin.Int+299> mov rcx, qword ptr [rsp] RCX, [0x7fffffffd740] => 0x7ffff6bbe848 —▸ 0x284130 (kclass:okio[Buffer]) ◂— 0x284130 /* '0A(' */
0x254c1f <kfun:okio.Buffer#read(kotlin.ByteArray;kotlin.Int;kotlin.Int){}kotlin.Int+303> sub qword ptr [rcx + 0x10], rax [0x7ffff6bbe858] <= 0 (3 - 3)
0x254c23 <kfun:okio.Buffer#read(kotlin.ByteArray;kotlin.Int;kotlin.Int){}kotlin.Int+307> mov eax, dword ptr [r13 + 0x20] EAX, [0x7ffff6abe3c8] => 3
0x254c27 <kfun:okio.Buffer#read(kotlin.ByteArray;kotlin.Int;kotlin.Int){}kotlin.Int+311> cmp eax, dword ptr [r13 + 0x24] 3 - 3 EFLAGS => 0x246 [ cf PF af ZF sf IF df of ac ]
0x254c2b <kfun:okio.Buffer#read(kotlin.ByteArray;kotlin.Int;kotlin.Int){}kotlin.Int+315> ✘ jne kfun:okio.Buffer#read(kotlin.ByteArray;kotlin.Int;kotlin.Int){}kotlin.Int+566 <kfun:okio.Buffer#read(kotlin.ByteArray;kotlin.Int;kotlin.Int){}kotlin.Int+566>
0x254c31 <kfun:okio.Buffer#read(kotlin.ByteArray;kotlin.Int;kotlin.Int){}kotlin.Int+321> xorps xmm0, xmm0
────────────────────────────────────────────────────────────────────────────────────────────────────[ STACK ]────────────────────────────────────────────────────────────────────────────────────────────────────
00:0000│ rsp 0x7fffffffd738 —▸ 0x254c0f (kfun:okio.Buffer#read(kotlin.ByteArray;kotlin.Int;kotlin.Int){}kotlin.Int+287) ◂— mov qword ptr [rsp + 0x20], r15
01:0008│ 0x7fffffffd740 —▸ 0x7ffff6bbe848 —▸ 0x284130 (kclass:okio[Buffer]) ◂— 0x284130 /* '0A(' */
02:0010│ 0x7fffffffd748 —▸ 0x7fffffffd8f0 —▸ 0x7fffffffdb40 —▸ 0x7fffffffdb60 ◂— 0
03:0018│ 0x7fffffffd750 ◂— 0x600000000
04:0020│ 0x7fffffffd758 —▸ 0x7ffff6abe3a8 —▸ 0x2839e0 (kclass:okio[Segment]) ◂— 0x2839e0 (kclass:okio[Segment])
05:0028│ 0x7fffffffd760 ◂— 0
... ↓ 2 skipped
──────────────────────────────────────────────────────────────────────────────────────────────────[ BACKTRACE ]──────────────────────────────────────────────────────────────────────────────────────────────────
► 0 0x7ffff7d88ad2 __memmove_avx_unaligned_erms+82
1 0x254c0f kfun:okio.Buffer#read(kotlin.ByteArray;kotlin.Int;kotlin.Int){}kotlin.Int+287
2 0x25c315 kfun:#main(kotlin.Array<kotlin.String>){}+7173
3 0x26c141 Init_and_run_start+497
4 0x26c23b main+11
5 0x7ffff7c2a1ca __libc_start_call_main+122
6 0x7ffff7c2a28b __libc_start_main+139
7 0x22aa89 _start+41
...
Step Instructionをしhexdumpをしてみると、実際に配列へのアドレスが書き込まれています。
pwndbg> si
Thread 1 "chall" hit Hardware watchpoint 16: *(0x7fffffffd7f0 + 0xD0)
Old value = 2181376
New value = 2181448
Thread 1 "chall" hit Hardware watchpoint 17: *0x7FFFFFFFD8C0
...
pwndbg> hexdump 0x7FFFFFFFD8C0
+0000 0x7fffffffd8c0 48 49 21 00 00 00 00 00 30 e1 af f6 ff 7f 00 00 │HI!.....│0.......│
+0010 0x7fffffffd8d0 70 ff ff ff ff ff ff ff d0 6e 2a 00 00 00 00 00 │p.......│.n*.....│
+0020 0x7fffffffd8e0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 │........│........│
+0030 0x7fffffffd8f0 40 db ff ff ff 7f 00 00 00 00 00 00 43 00 00 00 │@.......│....C...│
pwndbg>
ここでBack Traceを見てみると、kfun:#main関数の途中である0x25c315が、このアクセスした処理の呼び出し元のようです。(Return先)
► 0 0x7ffff7d88ad2 __memmove_avx_unaligned_erms+82
1 0x254c0f kfun:okio.Buffer#read(kotlin.ByteArray;kotlin.Int;kotlin.Int){}kotlin.Int+287
2 0x25c315 kfun:#main(kotlin.Array<kotlin.String>){}+7173
3 0x26c141 Init_and_run_start+497
4 0x26c23b main+11
5 0x7ffff7c2a1ca __libc_start_call_main+122
6 0x7ffff7c2a28b __libc_start_main+139
7 0x22aa89 _start+41
ここを見てみると、ファイル読み出しの処理での書き込みであることが判明します。
しかし直接 var_278 を渡しているわけではないようです。
ここでスタックの構造を見てみると、file_content_buffer, eight_contast_val, var_278が連続して並んでいることがわかります。
また、初期化時も並んでいることがわかります。
ここからこれらの変数はそれぞれ独立したものではなく、file_content_bufferから始まる一つの構造体であることが想像できます。
それぞれ、
- 配列の型データ
- 配列の長さ
- 配列のデータ
という形になっていると考えることができます。
しかし、このままだと依然として分かりづらいので、IDAの機能であるLocal Typesを用います。
Array という名前の構造体を作成し、デコンパイラにも適用します。
これにより、データの流れがわかりやすくなりました。(変数名も合わせて変えています)
暗号化
パディングの処理が完了した後は、encrypt 関数を2回呼び出しファイルから読み出したデータを暗号化しています。
その後、暗号化した8バイト分のファイルを実際に書き込んでいます。
その後、8回分ループをして何かをしています。
キー配列関連の関数呼び出しがあるので、キー更新の処理じゃないかと予想をし、詳しい解析は後に回します。
その後、読んだバイト数が8バイトではない(8未満)の場合、ファイルを閉じて暗号化のプロセスが終了します。
まとめ
今までのフローをチャートにし、視覚化するとこのような感じになります。
ここまでで、処理全体は「キー生成」「出力ファイル名構築」「8バイト単位の暗号化処理」「キー更新」に分けられることが分かりました。
次の記事では、このうち暗号鍵を生成している genKey 周辺を詳しく解析し、ファイル名から初期鍵を復元する方法を見ていきます。




























