Wiresharkでsshパケットを復号するためのssh key log作成方法 (2)
前回は、Wireshark 4.0.0以降で利用できるようになるssh復号機能を背景に、OpenSSHクライアントのデバッグシンボルがある場合にclient keyを抽出する方法を紹介した。
前回の方法では、関数名でbreakpointを設定したり、client keyをprintするのに変数名を指定したりできるようにOpenSSHをデバッグビルド(-g
オプションでコンパイル)する必要があった。
sshクライアント環境ごとにOpenSSHをデバッグビルドするのは実用性にやや難があるので、今回は、より汎用的な方法としてgdbを使ってUbuntuにバンドルされているsshコマンドからclient keyを抽出する。
検証環境
$ uname -srvi Linux 5.10.102.1-microsoft-standard-WSL2 #1 SMP Wed Mar 2 00:30:59 UTC 2022 x86_64 $ cat /etc/os-release NAME="Ubuntu" VERSION="20.04.4 LTS (Focal Fossa)" ID=ubuntu ID_LIKE=debian PRETTY_NAME="Ubuntu 20.04.4 LTS" VERSION_ID="20.04" HOME_URL="https://www.ubuntu.com/" SUPPORT_URL="https://help.ubuntu.com/" BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/" PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy" VERSION_CODENAME=focal UBUNTU_CODENAME=focal $ gdb --version GNU gdb (Ubuntu 9.2-0ubuntu1~20.04.1) 9.2 Copyright (C) 2020 Free Software Foundation, Inc. License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html> This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law.
$ ssh -V OpenSSH_8.2p1 Ubuntu-4ubuntu0.5, OpenSSL 1.1.1f 31 Mar 2020 $ file /usr/bin/ssh /usr/bin/ssh: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=48d5a82eb9975a1fe8ef84d033667aa76130726b, for GNU/Linux 3.2.0, stripped
以降の記述は、OpenSSH v8.2p1に基づいているため、他のバージョンでは実装が異なるかもしれない。
OpenSSHのデバッグシンボルがない場合の鍵抽出方針
題材として、前回同様にRaspberry Pi OSが動作しているホスト192.168.1.6
でUser=pi, Password=raspberryとしてパスワード認証を試みるsshパケットを復号する。 パケットキャプチャしつつ、gdbでsshクライアントを実行してclient keyを取り出す。
前回も言及したように、client keyの生成されるkex_gen_client()
は、処理の終わりの方でdebug("expecting SSH2_MSG_KEX_ECDH_REPLY")
を呼び出してデバッグログを出力している。
そのため、write
システムコールでのbreakpointを設定した状態でsshを-v
でverbose modeにして実行し、当該文字列を出力している部分を見つければ、比較的簡単にkex_gen_client()
のアドレスを特定できる。
kex_gen_client()
のアドレスが特定できれば、その後の処理からkex
のアドレスも分かるため、デバッグシンボルがない状況でも関数の終わりでkex
構造体の中身を読めば、最終的にclient keyを抽出することは可能である。
kex_gen_client()の特定
$ man 2 write
コマンドでwrite
システムコールの定義を確認すると、以下の通り第2引数にbufを渡して書き込む文字列を指定するようになっている。
SYNOPSIS #include <unistd.h> ssize_t write(int fd, const void *buf, size_t count);
gdbを起動し、write
にbreakpointを設定する。あわせて、停止時にbufの内容を確認するために第2引数の内容を表示するようにしたい。x86_64環境なので、第2引数はrsiレジスタを見ればよい。rsiレジスタの指す文字列をdisplayで自動表示するようにする。
準備ができたら、sshコマンドを-v
オプション付きで実行する。
そうすると、write
の呼び出しで停止し、sshのverbose出力を捉えられていることが分かる。この後、何度かcontinueして目的の文字列"expecting SSH2_MSG_KEX_ECDH_REPLY"
での呼び出しを見つけることになる。
$ gdb -q /usr/bin/ssh Reading symbols from /usr/bin/ssh... (No debugging symbols found in /usr/bin/ssh) (gdb) b write Breakpoint 1 at 0xb8e0 (gdb) display/s $rsi 1: x/s $rsi <error: No registers.> (gdb) r -v pi@192.168.1.6 Starting program: /usr/bin/ssh -v pi@192.168.1.6 [Thread debugging using libthread_db enabled] Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1". Breakpoint 1, __GI___libc_write (fd=2, buf=0x7fffffffaec0, nbytes=62) at ../sysdeps/unix/sysv/linux/write.c:25 25 ../sysdeps/unix/sysv/linux/write.c: No such file or directory. 1: x/s $rsi 0x7fffffffaec0: "OpenSSH_8.2p1 Ubuntu-4ubuntu0.5, OpenSSL 1.1.1f 31 Mar 2020\r\n"
目的の文字列を通り過ぎないように注意しながら、十数回continueを繰り返すのは大変なので、文字列一致でconditional breakpointを設定する(今回は、writeのシンボルが読み込まれていたので、bufを利用)。
(gdb) condition 1 strcmp(buf, "debug1: expecting SSH2_MSG_KEX_ECDH_REPLY\r\n")==0
continueすると、一気にwrite(2, "expecting SSH2_MSG_KEX_ECDH_REPLY", 43)
で停止するので、btでバックトレースを表示する。
(gdb) c Continuing. (snip) debug1: SSH2_MSG_KEXINIT sent debug1: SSH2_MSG_KEXINIT received debug1: kex: algorithm: curve25519-sha256 debug1: kex: host key algorithm: ecdsa-sha2-nistp256 debug1: kex: server->client cipher: chacha20-poly1305@openssh.com MAC: <implicit> compression: none debug1: kex: client->server cipher: chacha20-poly1305@openssh.com MAC: <implicit> compression: none Breakpoint 1, __GI___libc_write (fd=2, buf=0x7fffffffac40, nbytes=43) at ../sysdeps/unix/sysv/linux/write.c:25 25 in ../sysdeps/unix/sysv/linux/write.c 1: x/s $rsi 0x7fffffffac40: "debug1: expecting SSH2_MSG_KEX_ECDH_REPLY\r\n" (gdb) bt #0 __GI___libc_write (fd=2, buf=0x7fffffffac40, nbytes=43) at ../sysdeps/unix/sysv/linux/write.c:25 #1 0x000055555559a344 in ?? () #2 0x000055555559a728 in ?? () #3 0x00005555555b7327 in ?? () #4 0x00005555555b5035 in ?? () #5 0x00005555555a3cba in ?? () #6 0x00005555555a3d6d in ?? () #7 0x0000555555576f06 in ?? () #8 0x00005555555732ef in ?? () #9 0x0000555555563652 in ?? () #10 0x00007ffff7a61083 in __libc_start_main (main=0x555555561200, argc=3, argv=0x7fffffffde98, init=<optimized out>, fini=<optimized out>, rtld_fini=<optimized out>, stack_end=0x7fffffffde88) at ../csu/libc-start.c:308 #11 0x0000555555564dce in ?? ()
今、OpenSSHのdebug()
経由で呼び出されたwrite
で止まっているので、いくつか上のフレームがdebug()
で、それを呼び出している部分がkex_gen_client()
ということになる。
finで関数の終わりまで実行しながら、適当に手前の逆アセンブルを見る。
(gdb) d display 1 (gdb) fin Run till exit from #0 __GI___libc_write (fd=2, buf=0x7fffffffac40, nbytes=43) at ../sysdeps/unix/sysv/linux/write.c:25 debug1: expecting SSH2_MSG_KEX_ECDH_REPLY 0x000055555559a344 in ?? () Value returned is $2 = 43 (gdb) x/16i $rip-0x20 0x55555559a324: test $0x8080,%eax 0x55555559a329: cmove %ecx,%eax 0x55555559a32c: lea 0x2(%rdx),%rcx 0x55555559a330: cmove %rcx,%rdx 0x55555559a334: mov %eax,%ebx 0x55555559a336: add %al,%bl 0x55555559a338: sbb $0x3,%rdx 0x55555559a33c: sub %r15,%rdx 0x55555559a33f: callq 0x55555555f8e0 <write@plt> => 0x55555559a344: pop %rax 0x55555559a345: pop %rdx 0x55555559a346: jmpq 0x55555559a118 0x55555559a34b: callq 0x555555560720 <__stack_chk_fail@plt> 0x55555559a350 <error>: endbr64 0x55555559a354 <error+4>: sub $0xd8,%rsp 0x55555559a35b <error+11>: mov %rdi,%r10 (gdb) fin Run till exit from #0 0x000055555559a344 in ?? () 0x000055555559a728 in ?? () (gdb) x/16i $rip-0x20 0x55555559a708: add %al,(%rax) 0x55555559a70a: lea 0x20(%rsp),%rax 0x55555559a70f: mov %rax,0x10(%rsp) 0x55555559a714: movl $0x8,(%rsp) 0x55555559a71b: movl $0x30,0x4(%rsp) 0x55555559a723: callq 0x55555559a020 => 0x55555559a728: mov 0x18(%rsp),%rax 0x55555559a72d: xor %fs:0x28,%rax 0x55555559a736: jne 0x55555559a740 0x55555559a738: add $0xd8,%rsp 0x55555559a73f: retq 0x55555559a740: callq 0x555555560720 <__stack_chk_fail@plt> 0x55555559a745: data16 nopw %cs:0x0(%rax,%rax,1) 0x55555559a750: endbr64 0x55555559a754: sub $0xd8,%rsp 0x55555559a75b: mov %rdi,%r10 (gdb) fin Run till exit from #0 0x000055555559a728 in ?? () 0x00005555555b7327 in ?? () (gdb) x/16i $rip-0x20 0x5555555b7307: shlb $0x48,-0x5f(%rbp) 0x5555555b730b: mov %ebp,%edi 0x5555555b730d: callq 0x55555559f270 0x5555555b7312: mov %eax,%r12d 0x5555555b7315: test %eax,%eax 0x5555555b7317: jne 0x5555555b72ab 0x5555555b7319: lea 0x2a4a0(%rip),%rdi # 0x5555555e17c0 0x5555555b7320: xor %eax,%eax 0x5555555b7322: callq 0x55555559a680 => 0x5555555b7327: lea -0x75e(%rip),%rdx # 0x5555555b6bd0 0x5555555b732e: mov $0x1f,%esi 0x5555555b7333: mov %rbp,%rdi 0x5555555b7336: callq 0x5555555a3c20 0x5555555b733b: jmpq 0x5555555b72ab 0x5555555b7340: mov %rbx,%rdi 0x5555555b7343: callq 0x5555555c1f60
#3のフレーム(0x5555555b7327)まで上ったところで、lea 0x2a4a0(%rip),%rdi
でrdiレジスタに値をセットしてからcallq 0x55555559a680
している部分が引数を1つだけ取るdebug()
に見える。
rdiレジスタにセットされる0x5555555e17c0の内容を確認する。
(gdb) x/s 0x5555555e17c0 0x5555555e17c0: "expecting SSH2_MSG_KEX_ECDH_REPLY"
0x5555555b7322のcallq
は、見事にkex_gen_client
でのdebug
の呼び出し方と一致しているので、#4のフレームの手前の逆アセンブルを調べる。
(gdb) x/8i 0x5555555b5035-0x10 0x5555555b5025: add %al,(%rax) 0x5555555b5027: test %rax,%rax 0x5555555b502a: je 0x5555555b526c 0x5555555b5030: mov %rbp,%rdi 0x5555555b5033: callq *%rax 0x5555555b5035: mov %eax,%r14d 0x5555555b5038: jmpq 0x5555555b4af0 0x5555555b503d: nopl (%rax)
callq *%rax
でkex_gen_client
が呼び出されていると考えられるので、0x5555555b5033にbreakpointを設定して実行しなおす。
(gdb) d Delete all breakpoints? (y or n) y (gdb) b *0x5555555b5033 Breakpoint 2 at 0x5555555b5033 (gdb) k Kill the program being debugged? (y or n) y [Inferior 1 (process 235) killed] (gdb) r -v pi@192.168.1.6 Starting program: /usr/bin/ssh -v pi@192.168.1.6 [Thread debugging using libthread_db enabled] Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1". OpenSSH_8.2p1 Ubuntu-4ubuntu0.5, OpenSSL 1.1.1f 31 Mar 2020 (snip) Breakpoint 2, 0x00005555555b5033 in ?? () (gdb) x/i $rip => 0x5555555b5033: callq *%rax (gdb) si 0x00005555555b7270 in ?? () (gdb) fin Run till exit from #0 0x00005555555b7270 in ?? () debug1: expecting SSH2_MSG_KEX_ECDH_REPLY 0x00005555555b5035 in ?? ()
callq *%rax
で0x5555555b7270が呼び出されており、finで終わりまで実行すると、debug1:~文字列が出力されることが分かる。
このことから、0x5555555b7270がkex_gen_client()
と特定できた。
client keyの取り出し
特定したkex_gen_client()
の0x5555555b7270でbreakpointを設定し直して、sshを実行する。直接breakpointを設定するので、この段階でsshの実行時に-v
オプションは不要(付けても良い)。
kex_gen_client()
は第1引数でstruct ssh *ssh
を受け取るので、停止時点でのrdiレジスタの値がssh
のアドレスを指している。
$ gdb -q /usr/bin/ssh Reading symbols from /usr/bin/ssh... (No debugging symbols found in /usr/bin/ssh) (gdb) b *0x5555555b7270 Breakpoint 1 at 0x5555555b7270 (gdb) r pi@192.168.1.6 Starting program: /usr/bin/ssh pi@192.168.1.6 [Thread debugging using libthread_db enabled] Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1". Breakpoint 1, 0x00005555555b7270 in ?? () (gdb) i r rdi rdi 0x55555563d2b0 93824993186480 (gdb) x/16i $rip => 0x5555555b7270: endbr64 0x5555555b7274: push %r12 0x5555555b7276: push %rbp 0x5555555b7277: mov %rdi,%rbp 0x5555555b727a: push %rbx 0x5555555b727b: mov 0x8(%rdi),%rbx 0x5555555b727f: mov 0x48(%rbx),%eax 0x5555555b7282: cmp $0x8,%eax 0x5555555b7285: je 0x5555555b7340 0x5555555b728b: ja 0x5555555b72b8 0x5555555b728d: cmp $0x4,%eax 0x5555555b7290: jbe 0x5555555b7360 0x5555555b7296: cmp $0x7,%eax 0x5555555b7299: jne 0x5555555b72d0 0x5555555b729b: mov %rbx,%rdi 0x5555555b729e: callq 0x5555555c1c00
前回同様に、まず使用されているKEXアルゴリズムを示すkex->kex_type
の値を調べる。
kex_gen_client
関数の処理では、まずstruct kex *kex = ssh->kex;
とint r;
でローカル変数が初期化された後、switch (kex->kex_type)
に入る。
あわせて、ssh構造体の定義を確認すると、1番目のメンバ変数はstate
へのポインタで、2番目のメンバ変数にkex
構造体へのポインタが入っている。
これらを踏まえて、上記逆アセンブルを解釈すると、mov 0x8(%rdi),%rbx
でrbxレジスタにssh->kex
の値が入り、mov 0x48(%rbx),%eax
でeaxレジスタにkex->kex_type
の値が入ることが分かる。
7回ステップ実行して、ここまで実行する。
(gdb) si 7 0x00005555555b7282 in ?? () (gdb) i r rbx eax rbx 0x55555563db30 93824993188656 eax 0x8 8 (gdb) x/2ga $rdi 0x55555563d2b0: 0x55555563ad90 0x55555563db30
この時点で、rbxレジスタの値がssh->kex
の値を指している。rdiレジスタの指すアドレスから2番目のポインタが指すアドレスと一致していることからも、ssh構造体の定義通りになっている。
また、eaxレジスタの値はkex->kex_type
の値で8
なので、enum kex_exchange
の定義からKEX_C25519_SHA256
と分かる。
ここで、kex
構造体のシンボルがないため、c25519_client_key
までのオフセットが分からない問題に直面する。
参考に、前回デバッグシンボル付きでコンパイルしたOpenSSH 8.2p1のkex構造体の大きさを調べると1984
となっている。
アーキテクチャとOpenSSHのバージョンが同じなので、Ubuntu向けビルドとkex構造体の配置も同じと考えてしまいそうだが、コンパイラのアラインメント等でUbuntu向けビルドとはkex構造体の各メンバ変数のオフセットは異なる可能性がある*1。
$ gdb -q ./ssh Reading symbols from ./ssh... (gdb) p sizeof(struct kex) $1 = 1984 (gdb) p (int)&((struct kex*)0)->my $2 = 96 (gdb) p (int)&((struct kex*)0)->c25519_client_key $3 = 312
そこで少し強引な方法ではあるが、kex
の先頭から1984バイトより少し余裕を持たせた2048バイト分にwatchpointを設定して、メモリの値に変化があった部分がclient keyの書き込みとみなすこととする。
kex_gen_client()
の戻り先である、0x5555555b5035にtemporary breakpointを設定したうえで、続行する。
(gdb) watch (char[2048])*0x55555563db30 Watchpoint 2: (char[2048])*0x55555563db30 (gdb) bt #0 0x00005555555b7282 in ?? () #1 0x00005555555b5035 in ?? () #2 0x00005555555a3cba in ?? () #3 0x00005555555a3d6d in ?? () #4 0x0000555555576f06 in ?? () #5 0x00005555555732ef in ?? () #6 0x0000555555563652 in ?? () #7 0x00007ffff7a61083 in __libc_start_main (main=0x555555561200, argc=2, argv=0x7fffffffde98, init=<optimized out>, fini=<optimized out>, rtld_fini=<optimized out>, stack_end=0x7fffffffde88) at ../csu/libc-start.c:308 #8 0x0000555555564dce in ?? () (gdb) tb *0x00005555555b5035 Temporary breakpoint 3 at 0x5555555b5035 (gdb) c Continuing. Watchpoint 2: (char[2048])*0x55555563db30 Old value = '\000' <repeats 16 times>, "\340\305cUUU\000\000 \263cUUU\000\000@\000\000\000@", '\000' <repeats 11 times>, "\340'dUUU\000\000\220\004dUUU\000\000\002\000\000\000\237\001\000\000\b", '\000' <repeats 23 times>, " \231cUUU\000\000\220\310cUUU\000\000p\231cUUU\000\000p\224cUUU\000\000\000\000\000\000\003\000\000\000\002", '\000' <repeats 39 times>, "P6WUUU", '\000' <repeats 34 times>... New value = '\000' <repeats 16 times>, "\340\305cUUU\000\000 \263cUUU\000\000@\000\000\000@", '\000' <repeats 11 times>, "\340'dUUU\000\000\220\004dUUU\000\000\002\000\000\000\237\001\000\000\b", '\000' <repeats 23 times>, " \231cUUU\000\000\220\310cUUU\000\000p\231cUUU\000\000p\224cUUU\000\000\000\000\000\000\003\000\000\000\002", '\000' <repeats 39 times>, "P6WUUU", '\000' <repeats 34 times>... __memmove_sse2_unaligned_erms () at ../sysdeps/x86_64/multiarch/memmove-vec-unaligned-erms.S:241 241 ../sysdeps/x86_64/multiarch/memmove-vec-unaligned-erms.S: No such file or directory. (gdb) x/8i $rip-0x10 0x7ffff7af8bbe <__memmove_sse2_unaligned_erms+14>: cmp $0x20,%edx 0x7ffff7af8bc1 <__memmove_sse2_unaligned_erms+17>: ja 0x7ffff7af8c41 <__memmove_sse2_unaligned_erms+145> 0x7ffff7af8bc3 <__memmove_sse2_unaligned_erms+19>: movups (%rsi),%xmm0 0x7ffff7af8bc6 <__memmove_sse2_unaligned_erms+22>: movups -0x10(%rsi,%rdx,1),%xmm1 0x7ffff7af8bcb <__memmove_sse2_unaligned_erms+27>: movups %xmm0,(%rdi) => 0x7ffff7af8bce <__memmove_sse2_unaligned_erms+30>: movups %xmm1,-0x10(%rdi,%rdx,1) 0x7ffff7af8bd3 <__memmove_sse2_unaligned_erms+35>: retq 0x7ffff7af8bd4 <__memmove_sse2_unaligned_erms+36>: cmp 0x1358dd(%rip),%rdx # 0x7ffff7c2e4b8 <__x86_shared_non_temporal_threshold> (gdb) i r rdi rdi 0x55555563dcb8 93824993189048
movups %xmm0,(%rdi)
でkex->c25519_client_key
への実際の書き込みが行われたと思われるので、rdiレジスタの指す0x55555563dcb8が、kex->c25519_client_key
のアドレスとなる。
kex
先頭からのオフセットは、0x55555563dcb8 - 0x55555563db30 = 392
で、上記自前OpenSSHビルドの312
と異なっている。
ともあれ、乖離した値でもないので、watchpointを削除した後にkex_gen_client
の終わりまで続行して、client keyの値を取り出す。
CURVE25519_SIZE
は32
なので、kex->c25519_client_key
のアドレスから32バイトを表示する。
(gdb) d 2 (gdb) c Continuing. Temporary breakpoint 3, 0x00005555555b5035 in ?? () (gdb) x/32bx 0x55555563dcb8 0x55555563dcb8: 0x07 0x09 0x30 0x71 0x14 0x42 0x37 0x04 0x55555563dcc0: 0xc3 0xf1 0xa7 0xe4 0xb4 0x9c 0xae 0x14 0x55555563dcc8: 0xde 0xb1 0x36 0x8f 0x2c 0x7d 0x07 0x5e 0x55555563dcd0: 0xa5 0xc9 0x67 0x09 0x8d 0x32 0xc2 0x1f
cookieの取り出し
kex構造体の定義を確認すると、cookieの格納されているkex->my
は、kex->kex_type
の3つ先のメンバ変数となっている。
前節でのkex_gen_client()
の逆アセンブル結果から、kex->kex_type
へのオフセットは0x48
と判明している(mov 0x48(%rbx),%eax
)。
kex構造体の定義通り、kex_type
の3つ先にmy
のポインタ0x0000555555639920が入っているので、sshbuf構造体の定義より、my->d
の0x0000555555643a80に入っているcookieの値を表示する。
(gdb) x/8gx 0x55555563db30+0x48 0x55555563db78: 0x0000000000000008 0x0000000000000000 0x55555563db88: 0x0000000000000000 0x0000555555639920 0x55555563db98: 0x000055555563c890 0x0000555555639970 0x55555563dba8: 0x0000555555639470 0x0000000300000000 (gdb) x/8gx 0x0000555555639920 0x555555639920: 0x0000555555643a80 0x0000555555643a80 0x555555639930: 0x0000000000000000 0x00000000000005d8 0x555555639940: 0x0000000008000000 0x0000000000000600 0x555555639950: 0x0000000000000000 0x0000000000000001 (gdb) x/16bx 0x0000555555643a80 0x555555643a80: 0x09 0xb7 0x04 0x00 0xa4 0xb4 0xa5 0x67 0x555555643a88: 0x39 0xaa 0x2c 0xc4 0x2c 0x45 0xfd 0xf1
ssh key logファイルの作成とWiresharkへの設定
cookieとclient keyの対が揃ったので、以下のようなssh key logfile形式(<cookie> <key>
)のssh key logファイルを作成する。
09b70400a4b4a56739aa2cc42c45fdf1 0709307114423704c3f1a7e4b49cae14deb1368f2c7d075ea5c967098d32c21f
Wiresharkの設定で作成したssh key logfileを指定する。 正しく設定されていれば、画面のようにUser=pi, Password=raspberryでパスワード認証を試みる様子が復号される。
Wiresharkでsshパケットを復号するためのssh key log作成方法 (1)
TL;DR
- OpenSSHをデバッグビルドできる場合、gdbで
b kexgen.c:kex_gen_client
した後にfin
したタイミングでkex
構造体にclient keyデータが入っている - Wiresharkのssh key log formatで鍵を記録するためのgdbコマンドファイルを作成した
- デバッグシンボルがない場合は、
ssh -v
で実行したときにwrite(2, "debug1: expecting SSH2_MSG_KEX_ECDH_REPLY", 43)
しているところの呼び出し元がkexgen.c
のkex_gen_client()
なので、同様に関数の終わりでkex
構造体を読むと良い
背景
2022年1月にssh dissectorへのssh decryptionの実装がマージされ、Wireshark 4.0.0からsshパケットの復号ができるようになると見込まれる。
Wiresharkにssh decryptionが実装されていたので試してみる。SSLKEYLOGFILEみたいな仕組みがないので鍵の取り出しが超面倒https://t.co/qLYps4rxa4 pic.twitter.com/zmtOgn1rJj
— jptomoya (@_jptomoya) 2022年9月5日
Wiresharkでhttpsパケットを復号する方法として、環境変数SSLKEYLOGFILE
を指定してブラウザにセッション鍵を記録させる方法が知られている。
一方、Linuxディストリビューションにバンドルされ、広く利用されているOpenSSHクライアントには、このような仕組みがないため、sshセッション鍵の記録は敷居が高い。
また、-v -v -v
オプションを指定して、Verbose modeを最大にしてsshコマンドを実行しても鍵情報が出力されることはない。
そこで、OpenSSHをデバッグビルドしてgdbでWiresharkで利用できる形式のclient keyを抽出する方法をまとめた。
(ビルドするのであれば鍵情報を出力するようにソースを変更したほうが楽だったのでは) ⇒ ここで得た知見は、デバッグシンボルのないsshクライアントからの鍵抽出にも利用できる。
検証環境
$ uname -srvi Linux 5.10.102.1-microsoft-standard-WSL2 #1 SMP Wed Mar 2 00:30:59 UTC 2022 x86_64 $ cat /etc/os-release NAME="Ubuntu" VERSION="20.04.4 LTS (Focal Fossa)" ID=ubuntu ID_LIKE=debian PRETTY_NAME="Ubuntu 20.04.4 LTS" VERSION_ID="20.04" HOME_URL="https://www.ubuntu.com/" SUPPORT_URL="https://help.ubuntu.com/" BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/" PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy" VERSION_CODENAME=focal UBUNTU_CODENAME=focal $ gdb --version GNU gdb (Ubuntu 9.2-0ubuntu1~20.04.1) 9.2 Copyright (C) 2020 Free Software Foundation, Inc. License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html> This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law.
$ ssh -V OpenSSH_8.2p1 Ubuntu-4ubuntu0.5, OpenSSL 1.1.1f 31 Mar 2020 $ file /usr/bin/ssh /usr/bin/ssh: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=48d5a82eb9975a1fe8ef84d033667aa76130726b, for GNU/Linux 3.2.0, stripped
以降の記述は、OpenSSH v8.2p1に基づいているため、他のバージョンでは実装が異なるかもしれない。
Wiresharkでsshパケットを復号する方法
Wiresharkでsshパケットを復号するには、ssh dissectorに復号したいsshセッションの鍵を設定する(httpsと同様)。前述の通り、ssh decryption機能はWireshark 4.0.0から利用できるようになる見込みで、Wireshark 3.6.xでは(おそらく)利用できない点に注意*1。Wiresharkの[設定] - [Protocols] - [SSH]でssh key logと呼ばれるファイルを指定する。
フォーマットは、<hex-encoded-cookie> <hex-encoded-key>
とされており、復号したいsshセッションのcookieとkeyをスペース区切りで記述したテキストファイルを用意すれば良いことが分かる。
Wiresharkのソースコードを参照すると、ssh key logの詳細な説明が見つけられる。
/* File format: each line follows the format "<cookie> <key>". * <cookie> is the hex-encoded (client or server) 16 bytes cookie * (32 characters) found in the SSH_MSG_KEXINIT of the endpoint whose * private random is disclosed. * <key> is the private random number that is used to generate the DH * negotiation (length depends on algorithm). In RFC4253 it is called * x for the client and y for the server. * For openssh and DH group exchange, it can be retrieved using * DH_get0_key(kex->dh, NULL, &server_random) * for groupN in file kexdh.c function kex_dh_compute_key * for custom group in file kexgexs.c function input_kex_dh_gex_init * For openssh and curve25519, it can be found in function kex_c25519_enc * in variable server_key. * * Example: * 90d886612f9c35903db5bb30d11f23c2 DEF830C22F6C927E31972FFB20B46C96D0A5F2D5E7BE5A3A8804D6BFC431619ED10AF589EEDFF4750DEA00EFD7AFDB814B6F3528729692B1F2482041521AE9DC */
以降、ssh key logで指定するkeyをclient keyと呼ぶ。
client keyの取り出し~ssh key logファイル作成
題材として、ここではRaspberry Pi OSが動作しているホスト192.168.1.10
でUser=pi, Password=raspberryとしてパスワード認証を試みるsshパケットを復号する。
パケットキャプチャしつつ、gdbでsshクライアントを実行してclient keyを取り出す。
OpenSSHのビルド
検証環境のUbuntuにインストールされていたsshクライアントと同じ、OpenSSH 8.2p1のソースコードを公式サイトからダウンロードしてビルドする。
configure時にCFLAGS=-g
を指定することで、デバッグシンボルを含めてコンパイルする。
なお、コンパイルにはOpenSSLとzlibが必要なので前もってインストールしておく。Ubuntuの場合、apt update && apt install zlib1g-dev libssl-dev
。
$ tar xzf openssh-8.2p1.tar.gz $ cd openssh-8.2p1/ $ ./configure -q CFLAGS=-g OpenSSH has been configured with the following options: User binaries: /usr/local/bin System binaries: /usr/local/sbin Configuration files: /usr/local/etc Askpass program: /usr/local/libexec/ssh-askpass Manual pages: /usr/local/share/man/manX PID file: /var/run Privilege separation chroot path: /var/empty sshd default user PATH: /usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin Manpage format: doc PAM support: no OSF SIA support: no KerberosV support: no SELinux support: no MD5 password support: no libedit support: no libldns support: no Solaris process contract support: no Solaris project support: no Solaris privilege support: no IP address in $DISPLAY hack: no Translate v4 in v6 hack: yes BSD Auth support: no Random number source: OpenSSL internal ONLY Privsep sandbox style: seccomp_filter PKCS#11 support: yes U2F/FIDO support: yes Host: x86_64-pc-linux-gnu Compiler: cc Compiler flags: -g -pipe -Wno-error=format-truncation -Wall -Wpointer-arith -Wuninitialized -Wsign-compare -Wformat-security -Wsizeof-pointer-memaccess -Wno-pointer-sign -Wno-unused-result -Wimplicit-fallthrough -fno-strict-aliasing -D_FORTIFY_SOURCE=2 -ftrapv -fno-builtin-memset -fstack-protector-strong -fPIE Preprocessor flags: -D_XOPEN_SOURCE=600 -D_BSD_SOURCE -D_DEFAULT_SOURCE Linker flags: -Wl,-z,relro -Wl,-z,now -Wl,-z,noexecstack -fstack-protector-strong -pie Libraries: -lcrypto -ldl -lutil -lz -lcrypt -lresolv $ make (snip) $ ls ./ssh ./ssh $ file ./ssh ./ssh: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=57c9b6dad20f5e4cb2d6cd78f7afb11af4bf41fe, for GNU/Linux 3.2.0, with debug_info, not stripped $ ./ssh -V OpenSSH_8.2p1, OpenSSL 1.1.1f 31 Mar 2020
gdbのセットアップと実行
sshクライアントでは、kexgen.cのkex_gen_client
でclient keyが生成される(KEXアルゴリズムがdiffie-hellman-group-exchange-sha1
とdiffie-hellman-group-exchange-sha256
のときを除く*2)。
そのため、kexgen.c
のkex_gen_client()
でbreakpointを設定しておき、実行する。
breakpointに到達すると、kex_gen_client()
の先頭で停止するのでfin
で関数の終わりまで実行する。このタイミングで、client keyがkex
構造体に格納されている。
$ gdb -q ./ssh Reading symbols from ./ssh... (gdb) b kexgen.c:kex_gen_client Breakpoint 1 at 0x7a15f: file kexgen.c, line 100. (gdb) r pi@192.168.1.10 Starting program: /tmp/openssh-8.2p1/ssh pi@192.168.1.10 [Thread debugging using libthread_db enabled] Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1". Breakpoint 1, kex_gen_client (ssh=0x5555555b1088 <sshpkt_get_end+35>) at kexgen.c:100 100 { (gdb) fin Run till exit from #0 kex_gen_client (ssh=0x5555555b1088 <sshpkt_get_end+35>) at kexgen.c:100 0x00005555555cb466 in kex_input_kexinit (type=20, seq=0, ssh=0x555555640150) at kex.c:624 624 return (kex->kex[kex->kex_type])(ssh); Value returned is $1 = 0
client keyの取り出し
kex->kex_type
の値によって、kex
でclient keyが格納されるメンバ変数が異なるため、まずkex_type
の値を調べる。
(gdb) p kex->kex_type $2 = 8
enum kex_exchange
の定義を確認すると、KEX_C25519_SHA256
と分かる。
enum kex_exchange {
KEX_DH_GRP1_SHA1,
KEX_DH_GRP14_SHA1,
KEX_DH_GRP14_SHA256,
KEX_DH_GRP16_SHA512,
KEX_DH_GRP18_SHA512,
KEX_DH_GEX_SHA1,
KEX_DH_GEX_SHA256,
KEX_ECDH_SHA2,
KEX_C25519_SHA256,
KEX_KEM_SNTRUP4591761X25519_SHA512,
KEX_MAX
};
KEX_C25519_SHA256
の場合は、kex->c25519_client_key
にclient keyが格納されているので、printして鍵を取り出す。
(gdb) p/x kex->c25519_client_key $3 = {0x4e, 0xb5, 0x70, 0x1a, 0x86, 0xfb, 0x60, 0xf3, 0x7e, 0x6e, 0xe6, 0xf8, 0xfa, 0xaf, 0x76, 0x32, 0x3e, 0x7c, 0xdf, 0x4c, 0x6c, 0xd3, 0x41, 0x61, 0xa, 0x2b, 0x12, 0x2f, 0xeb, 0x5e, 0xd9, 0x17}
kexgen.cのkex_gen_client()
の処理を追うと、以下のような対応であることが分かる。鍵交換がKEX_C25519_SHA256
以外のアルゴリズムだった場合は、対応するメンバ変数を参照する。
- KEX_DH_GRP1_SHA1 / KEX_DH_GRP14_SHA1 / KEX_DH_GRP14_SHA256 / KEX_DH_GRP16_SHA512 / KEX_DH_GRP18_SHA512 ->
kex->dh
- KEX_ECDH_SHA2 ->
kex->ec_client_key
- KEX_C25519_SHA256 ->
kex->c25519_client_key
cookieの取り出し
cookieはkex->my
に格納されている。OpenSSHのデバッグ用関数sshbuf_dump_data()
を利用して表示させる。
(gdb) p kex->my $4 = (struct sshbuf *) 0x555555641d50 (gdb) call sshbuf_dump_data(*kex->my, 16, stdout) 0000: b1 14 d4 5a 91 cc c7 7a 51 2f 0c 7c d3 2b 45 d7 ...Z...zQ/.|.+E. (gdb) c Continuing. pi@192.168.1.10's password: Permission denied, please try again. pi@192.168.1.10's password:
なお、cookieは鍵交換時に平文で流れているので、手動で取り出す場合は、パケットキャプチャからWireshark等で取り出すこともできる。
その場合は、Client: Key Exchange Init
パケットの値ssh.cookie
が目的の値になる。
ssh key logファイルの作成とWiresharkへの設定
cookieとclient keyの対が揃ったので、以下のようなssh key logファイルを前述の<cookie> <key>
フォーマットで作成する。
b114d45a91ccc77a512f0c7cd32b45d7 4eb5701a86fb60f37e6ee6f8faaf76323e7cdf4c6cd341610a2b122feb5ed917
Wiresharkの[設定] - [Protocols] - [SSH]でKey log filenameに作成したファイルを指定する。 正しく設定されていれば、画面のようにUser=pi, Password=raspberryでパスワード認証を試みる様子が復号される。
gdbコマンドファイル化
(2022/9/12) KEX_ECDH_SHA2, KEX_DH_GEX_SHA1, KEX_DH_GEX_SHA256に対応させた
ここまでの作業をssh接続毎に行うのは大変なので、ssh key log formatでclient keyを記録するするためのgdbコマンドファイルを作成した。書きなぐりなので、あまり安定性はない。 全ての鍵交換アルゴリズムに対応するのは大変だったので、KEX_DH_GRP1_SHA1, KEX_DH_GRP14_SHA1, KEX_DH_GRP14_SHA256, KEX_DH_GRP16_SHA512, KEX_DH_GRP18_SHA512, KEX_C25519_SHA256にのみ対応している。
gdbコマンドファイルを、sshkeylog
という名前で保存しgdb -x
で読み込ませるようにした。以下の実行例のように、gdbは、sshkey.log
というファイル名でcookieとclient keyをssh key log formatで記録する。
$ gdb -q -x ./sshkeylog --args ./ssh pi@192.168.1.10 Reading symbols from ./ssh... Breakpoint 1 at 0x7a15f: file kexgen.c, line 100. [Thread debugging using libthread_db enabled] Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1". Breakpoint 1, kex_gen_client (ssh=0x5555555b1088 <sshpkt_get_end+35>) at kexgen.c:100 100 { 0x00005555555cb466 in kex_input_kexinit (type=20, seq=0, ssh=0x555555640150) at kex.c:624 624 return (kex->kex[kex->kex_type])(ssh); Value returned is $1 = 0 pi@192.168.1.10: Permission denied (publickey). [Inferior 1 (process 26835) exited with code 0377] $ cat sshkey.log b1fc713fd32f9f2f25c0ae35b35f24fe db903bf73a258f969d24abbb09e99e93f7f3e07c43ce74a0fb0c43ca05b88c25
コマンドファイルの内容は以下。
OpenSSHのデバッグシンボルがない場合
client keyの取り出しでフォーカスしてきた、kex_gen_client()
は、処理の終わりの方でdebug("expecting SSH2_MSG_KEX_ECDH_REPLY")
を呼び出してデバッグログを出力するようになっている。
そのためsshを-v
でverbose modeにし、write
システムコールでのbreakpointを起点にすることで、比較的簡単にkex_gen_client()
を見つけることができる。
kex_gen_client()
が特定できれば、kex
のアドレスも分かるため、デバッグシンボルがない状況でも前述のように関数の終わりでkex
構造体の中身を読めば、最終的にclient keyを記録することは可能である。
詳細は別の機会にしたい。
変更履歴
ヤマハルータのモバイルインターネット接続機能で接続を維持する
RTX810で、公式ページの設定例そのままだと無通信時に切断されるので、常時接続するために入れたconfigメモ。 これだけ入れておけばルーター側要因で切断されることはないはず(たぶん)。
無論、無通信時にキャリア側から切断される場合はkeepalive等の対策を別途入れる必要がある。
pp select 1 pp always-on on mobile auto connect on mobile disconnect time off mobile disconnect input time off mobile disconnect output time off mobile access limit length off mobile access limit time off