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