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.

Ubuntuにインストールされていたsshクライアント

$ 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パケットを復号する。 パケットキャプチャしつつ、gdbsshクライアントを実行して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 *%raxkex_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_SIZE32なので、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パケット復号結果

*1:実際、前回の自前ビルドとUbuntu向けビルドでは異なった。

Wiresharkでsshパケットを復号するためのssh key log作成方法 (1)

TL;DR

  • OpenSSHをデバッグビルドできる場合、gdbb kexgen.c:kex_gen_clientした後にfinしたタイミングでkex構造体にclient keyデータが入っている
  • Wiresharkssh key log formatで鍵を記録するためのgdbコマンドファイルを作成した
  • デバッグシンボルがない場合は、ssh -vで実行したときにwrite(2, "debug1: expecting SSH2_MSG_KEX_ECDH_REPLY", 43)しているところの呼び出し元がkexgen.ckex_gen_client()なので、同様に関数の終わりでkex構造体を読むと良い

背景

2022年1月にssh dissectorへのssh decryptionの実装がマージされ、Wireshark 4.0.0からsshパケットの復号ができるようになると見込まれる。

Wiresharkhttpsパケットを復号する方法として、環境変数SSLKEYLOGFILEを指定してブラウザにセッション鍵を記録させる方法が知られている。 一方、Linuxディストリビューションにバンドルされ、広く利用されているOpenSSHクライアントには、このような仕組みがないため、sshセッション鍵の記録は敷居が高い。 また、-v -v -vオプションを指定して、Verbose modeを最大にしてsshコマンドを実行しても鍵情報が出力されることはない。 そこで、OpenSSHをデバッグビルドしてgdbWiresharkで利用できる形式の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.

Ubuntuにインストールされていたsshクライアント

$ 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に基づいているため、他のバージョンでは実装が異なるかもしれない。

Wiresharksshパケットを復号する方法

Wiresharksshパケットを復号するには、ssh dissectorに復号したいsshセッションの鍵を設定する(httpsと同様)。前述の通り、ssh decryption機能はWireshark 4.0.0から利用できるようになる見込みで、Wireshark 3.6.xでは(おそらく)利用できない点に注意*1Wiresharkの[設定] - [Protocols] - [SSH]でssh key logと呼ばれるファイルを指定する。

Wireshark設定 - SSH Protocol

フォーマットは、<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パケットを復号する。 パケットキャプチャしつつ、gdbsshクライアントを実行して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-sha1diffie-hellman-group-exchange-sha256のときを除く*2)。 そのため、kexgen.ckex_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の取り出し

cookiekex->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でパスワード認証を試みる様子が復号される。

sshでパスワード認証を試みる様子が復号される

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を記録することは可能である。 詳細は別の機会にしたい。

変更履歴

  • (2022/9/17) 必ずしもkex_gen_clientでclient keyが生成されるわけではないことについて追記
  • (2022/9/12) gdbコマンドファイルをKEX_ECDH_SHA2, KEX_DH_GEX_SHA1, KEX_DH_GEX_SHA256に対応させた
  • (2022/9/11) gdbコマンドファイルをGitHub Gistに移動

*1:Wireshark 3.6.8でもssh key logfileを設定できるが、前述のPRでssh dissectorが刷新されている。Wireshark 3.6.xの実装がどこまでかは未確認。

*2:https://github.com/openssh/openssh-portable/blob/V_8_2/ssh_api.c#L130

ヤマハルータのモバイルインターネット接続機能で接続を維持する

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