
straceでSSHコマンドの内部を覗いてみた
こんにちは 人材育成室 育成メンバーチームで 研修中の はすと です。
以前、mitmproxyを使ってClaude Codeがどんな通信をしているのかネットワークの外側から覗いてみたことがありました。その後もSSHで毎日のようにサーバーへ接続しているのですが、ssh user@server と打ったその瞬間、Linuxカーネルへ何をお願いしているのかは考えたことがありませんでした。前回はネットワークの外側から通信を覗きましたが、今回はもう一段階下のレイヤー、アプリケーションとLinuxカーネルの境界をstraceで覗いてみることにしました。
本記事では、strace -f ssh user@server を実際に実行し、sshコマンドがどんなシステムコールを発行しているかを、実際に取得したログを見ながら追っていきます。
straceとは
以下のような流れになります。
Terminal
│
ssh
│
System Call
│
Linux Kernel
│
NIC
│
Server
straceは、アプリケーションとLinuxカーネルの境界で発生するシステムコールを観測するツールです。今回はSSHの仕組みそのものではなく、「sshコマンドがLinuxに何を依頼しているか」だけに焦点を当てます。
検証環境を用意する
macOSにはstraceが存在しないので(Linux専用のツールで、macOSはdtruss/dtraceという別系統になります)、Linux環境が必要です。手元ではcolimaでDockerがすでに動いていたので、使い捨てのUbuntuコンテナを1つ立てて、その中でクライアントもサーバーも完結させることにしました。
同じ手順を試せるように、必要なものと動作確認したバージョンを挙げておきます。
- Dockerが動く環境(colima・Docker Desktop・OrbStackなど、
dockerコマンドが使えればランタイムは問いません) - インターネット接続(
ubuntu:24.04イメージの取得に使用)
| ソフトウェア | バージョン |
|---|---|
| ホストOS | macOS(Apple Silicon, aarch64) |
| colima | 0.10.3 |
| Docker | Client 29.6.1 / Server 29.5.2 |
| コンテナOS | Ubuntu 24.04.4 LTS |
| OpenSSH | 9.6p1(Ubuntu-3ubuntu13.18) |
| strace | 6.8 |
DockerのClient/Server、sshのバージョン表記の内訳が気になった方へ
docker versionの出力を見ると、ClientとServerでバージョンが違っています(29.6.1 / 29.5.2)。Dockerは公式にクライアント・サーバー型のアーキテクチャを採っているためです。
Docker uses a client-server architecture. The Docker client talks to the Docker daemon, which does the heavy lifting of building, running, and distributing your Docker containers.
The Docker client and daemon can run on the same system, or you can connect a Docker client to a remote Docker daemon. The Docker client and daemon communicate using a REST API, over UNIX sockets or a network interface.
手元の環境で実際に確認すると、Client(/opt/homebrew/bin/docker、HomebrewでインストールしたmacOS/arm64バイナリ)と、Server(colimaのLinux VM内で動くdaemon、unix:///Users/hasutosasaki/.colima/default/docker.sock経由で接続)は別々のバイナリでした。Homebrew管理のCLIとcolima VM内蔵のdaemonがそれぞれ独立して更新されるため、バージョンにずれが出ていました。
sshのバージョン文字列(OpenSSH_9.6p1 Ubuntu-3ubuntu13.18, OpenSSL 3.0.13)にも内訳があります。
OpenSSH_9.6: OpenBSD側の本家バージョンp1: portable版(他OS向け移植版)のパッチレベル。本家とは別に振られる番号
The "p" in versions like "10.4p1" indicates a patchlevel for the portable distribution. This allows bug fixes and security patches to be released between major version updates.
Ubuntu-3ubuntu13.18: Ubuntuが独自にバックポートしたセキュリティパッチのリビジョン番号OpenSSL 3.0.13: リンクしている暗号ライブラリのバージョン(sshとは別物)
ちなみにOpenSSHの最新は10.4(2026-07-06リリース)で、Ubuntu 24.04は9.6p1のままセキュリティパッチだけをバックポートする運用でした。バージョン番号だけ見ると古そうですが、LTSとしては想定通りの状態です。
Ubuntu 24.04は2024年4月リリースのLTSで、サポートは2029年まで続くため、バージョンが古いことによる弊害は特にありません。
docker run -d --name ssh-strace-demo ubuntu:24.04 sleep infinity
コンテナの中にopenssh-server・openssh-client・straceを入れ、検証専用のユーザーsshdemoを作成し、その場で使い捨てのed25519鍵を生成しました。普段使っている自分の鍵やアカウントは一切使っていません。
openssh-serverとopenssh-clientの違い
パッケージの説明(apt show)を見ると役割がはっきり分かれています。
openssh-client: secure shell (SSH) client, for secure access to remote machines —ssh/scp/ssh-keygenなど、接続しに行く側のコマンド一式openssh-server: secure shell (SSH) server, for secure access from remote machines — 接続を受け付けるsshdデーモンと/etc/ssh/sshd_config
普段使うUbuntu/Macにはopenssh-clientだけが入っていることが多く、sshで外に繋ぎに行くことはできても、外から繋がれることはできません。今回は1つのコンテナにクライアント役とサーバー役を同居させる構成にしたため、両方インストールしています。
docker exec ssh-strace-demo bash -c "
apt-get update -qq
apt-get install -y -qq openssh-server openssh-client strace sudo
useradd -m -s /bin/bash sshdemo
"
docker exec ssh-strace-demo su - sshdemo -c "
ssh-keygen -t ed25519 -N '' -f ~/.ssh/id_ed25519 -C sshdemo-throwaway
cat ~/.ssh/id_ed25519.pub >> ~/.ssh/authorized_keys
"
サーバー役もクライアント役も同じコンテナに同居させて、ssh sshdemo@localhostで自分自身に接続する形にしています。これなら本物のリモートサーバーの情報を記事に出さずに済みますし、connectが失敗するパターンも自分の手元で自由に作れます。
なお、今回はlocalhostへの接続なので、DNS解決(getaddrinfoがDNSサーバーに問い合わせる部分)は対象外です。この点は検証範囲から意図的に外しています。
普通に接続できることを確認しておきます。
実行結果です。
$ ssh sshdemo@localhost echo CONNECT_OK
CONNECT_OK
ssh末尾に毎回コマンドを付けている理由
コマンドを指定しないとsshは対話シェルを起動しようとして疑似端末(pty)の割り当てを試みますが、今回docker exec(-it無し)経由で実行しているため正規の端末が無く、そのままだと入力待ちで固まってしまいます。末尾にコマンドを1つ渡すと、sshはそのコマンドをログインシェルの代わりに実行して結果を返すとすぐ終了するので、execveからexit_groupまで有限の流れとしてstraceで追えるようになります。
この挙動はssh(1)のマニュアルにも明記されています。
If a command is specified, it will be executed on the remote host instead of a login shell.
If an interactive session is requested, ssh by default will only request a pseudo-terminal (pty) for interactive sessions when the client has one. The flags -T and -t can be used to override this behaviour.
日本語にすると次のような内容です。
- コマンドが指定された場合、ログインシェルの代わりにそのコマンドがリモートホスト上で実行される
- 対話セッションが要求された場合(コマンド指定が無い場合)、クライアント側に端末があれば疑似端末(pty)をデフォルトで要求する。この挙動は
-T/-tオプションで上書きできる
ここからstraceで覗いていきます。
プロセスが起動する瞬間を見る
strace -f -o full_trace.log ssh sshdemo@localhost echo SSH_STRACE_OK
実行結果の先頭です。
execve("/usr/bin/ssh", ["ssh", "sshdemo@localhost", "echo", "SSH_STRACE_OK"], 0xffffc4d33330 /* 10 vars */) = 0
sshコマンドを打った瞬間に記録される最初の1行です。ここから先のすべての処理は、このプロセスが発行するシステムコールの連鎖として観測できます。
設定ファイルを読みに行く
sshは接続を試みる前に、複数の設定ファイルをopenatで開きに行きます。
openat(AT_FDCWD, "/home/sshdemo/.ssh/config", O_RDONLY) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/etc/ssh/ssh_config", O_RDONLY) = 3
openat(AT_FDCWD, "/etc/ssh/ssh_config.d/", O_RDONLY|O_NONBLOCK|O_DIRECTORY|O_CLOEXEC) = 3
~/.ssh/configが無い状態だとENOENT(ファイルが無い)で返ってきます。実際に~/.ssh/configを作ってから同じコマンドを実行すると、結果が変わります。
openat(AT_FDCWD, "/home/sshdemo/.ssh/config", O_RDONLY) = 3
ファイルの有無だけで、-1 ENOENTか= 3(オープン成功時のファイルディスクリプタ)かがきれいに切り替わりました。
秘密鍵を探す部分にも気になる点がありました。~/.ssh/id_ed25519しか用意していないにもかかわらず、openatのログには次のようなファイルが次々と登場します。
openat(AT_FDCWD, "/home/sshdemo/.ssh/id_rsa", O_RDONLY) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/home/sshdemo/.ssh/id_dsa", O_RDONLY) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/home/sshdemo/.ssh/id_ecdsa", O_RDONLY) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/home/sshdemo/.ssh/id_ecdsa_sk", O_RDONLY) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/home/sshdemo/.ssh/id_ed25519", O_RDONLY) = 3
openat(AT_FDCWD, "/home/sshdemo/.ssh/id_xmss", O_RDONLY) = -1 ENOENT (No such file or directory)
sshは「設定された1つの鍵」を読みに行くのではなく、対応している鍵形式(RSA/DSA/ECDSA/ECDSA-SK/Ed25519/Ed25519-SK/XMSS、それぞれの証明書版も含む)を総当たりで試し、存在したものだけを使っていました。存在しないファイルへのENOENTは、エラーというより「候補を1つずつ潰していった記録」として読めます。
存在しない鍵ファイルを試すことにセキュリティリスクは無いか
id_rsaやid_dsaなど、用意していない鍵までopenatされているのを見て気になったので調べてみました。
ログを見ると、鍵候補のopenat(ENOENTのものも含め)は、SSHプロトコルのバナー交換(write(3, "SSH-2.0-OpenSSH..."))より前に、ソケットとは別のファイルディスクリプタで完了しています。つまり鍵探索は完全にローカルのファイルシステム操作で完結していて、サーバーには何も送られていません。ファイルが無ければENOENTが返るだけで、それ以上何も起きようがありませんでした。
ssh_config(5)のマニュアルにも、存在しない鍵ファイルに関するセキュリティ上の注記は見当たりません。デフォルトの鍵候補を順に試すのは、明示的に意図された標準の挙動です。
ただし隣接する、実在するリスクがあります。これは「無い鍵」ではなく「ある鍵」の話です。
Specifies the maximum number of authentication attempts permitted per connection. Once the number of failures reaches half this value, additional failures are logged.
MaxAuthTriesはデフォルト6回です。ssh-agentに鍵をたくさん登録していたりIdentityFileが複数実在したりすると、存在する鍵を次々オファーする過程でこの上限を消費してしまい、本来使いたい鍵やパスワード認証にたどり着く前に接続を拒否されることがあります。
なお、複数の鍵が実際に有効な場合の挙動もssh -vで確認しました。RSA鍵とEd25519鍵の両方をauthorized_keysに登録した状態で接続すると、次のように優先順位順に1つずつオファーし、サーバーが受け入れた時点で即座に終了します。2番目の鍵が試されることはありませんでした。
debug1: Offering public key: /home/sshdemo/.ssh/id_rsa RSA SHA256:ULeD...
debug1: Server accepts key: /home/sshdemo/.ssh/id_rsa RSA SHA256:ULeD...
ソケットを作り、接続を試みる
socketとconnectのあたりを見ると、connectが3回も成功しているのに、実際のTCPソケット(SOCK_STREAM)は1つしか作られていませんでした。
実行結果です。
socket(AF_INET, SOCK_DGRAM|SOCK_CLOEXEC, IPPROTO_IP) = 3
connect(3, {sa_family=AF_INET, sin_port=htons(22), sin_addr=inet_addr("127.0.0.1")}, 16) = 0
getsockname(3, {sa_family=AF_INET, sin_port=htons(33292), sin_addr=inet_addr("127.0.0.1")}, [28 => 16]) = 0
close(3) = 0
socket(AF_INET6, SOCK_DGRAM|SOCK_CLOEXEC, IPPROTO_IP) = 3
connect(3, {sa_family=AF_INET6, sin6_port=htons(22), ... "::1" ...}, 28) = 0
getsockname(3, {sa_family=AF_INET6, sin6_port=htons(43626), ... "::1" ...}, [28]) = 0
close(3) = 0
socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP) = 3
connect(3, {sa_family=AF_INET6, sin6_port=htons(22), ... "::1" ...}, 28) = 0
connectと聞くと「サーバーへ接続すること」だと考えると思います。実際には、最初の2回はSOCK_DGRAM(UDPソケット)に対するconnectで、これは本当のパケットを1つも送らずに「このIPへ出て行くとしたら自分はどのアドレスを使うか」をカーネルに教えてもらうためだけの呼び出しでした。connectした直後にgetsocknameで自分のアドレスを確認し、そのままソケットを閉じています。本当に接続を試みているのは、最後のSOCK_STREAM(TCP)に対するconnect1回だけでした。
調べてみると、これはssh自身のロジックではなく、glibcのgetaddrinfo()がlocalhostの複数アドレス(127.0.0.1と::1)をRFC 3484(Default Address Selection)に基づいて並び替えるための処理でした。並び替えの結果、実際にTCP接続を確立したのは::1(IPv6のループバック)側でした。
なぜUDPソケットへのconnectだけで済むのか(glibcのソースコードで確認)
glibcのgetaddrinfo()の実装(nss/getaddrinfo.c)に、まさにこの処理をしているtry_connect()という関数がありました。各候補アドレスへUDPソケットでconnect+getsocknameを行い、本物のパケットを送らずに到達可能性と送信元アドレスだけを調べています。コード中にも理由がそのまま書かれていました。
/* We overwrite the type with SOCK_DGRAM since we do not want
connect() to connect to the other side. */
出典: nss/getaddrinfo.c - glibc公式リポジトリ(git cloneして該当箇所を直接確認)
try_connect()の実装自体は__connect()と__getsockname()を呼んでいるだけで、アドレスの選択自体はカーネルの経路表(ルーティングテーブル)に委ねられています。UDPソケットに明示的なbind無しでconnectすると、カーネルは「この宛先に出すならどのインターフェースのどのアドレスを使うか」を経路表から自動選択します(暗黙のbind)。つまりここで判明するのは、ローカルマシンに実在するインターフェースのアドレス(今回ならloの127.0.0.1/::1、eth0の172.17.0.4)の中から、経路表に基づいて実際に選ばれるものです。同じログに写っていたAF_NETLINKでのRTM_GETADDR(インターフェースのアドレス一覧取得)は、まさにこの判定に使うローカルアドレス一覧を集める処理でした。
`localhost`ではなく`eth0`のIPを指定したらどうなるか
ssh sshdemo@localhostの代わりにssh sshdemo@172.17.0.4(コンテナのeth0自身のIPアドレス)を指定したらどうなるか試してみました。127.0.0.1/::1は最初からループバック専用として予約されたアドレスですが、172.17.0.4はDockerのブリッジネットワークが割り当てた、他のコンテナからも到達できる普通のIPです。
実行結果です。
$ ip route get 172.17.0.4
local 172.17.0.4 dev lo src 172.17.0.4 uid 0
cache <local>
eth0自身のIPアドレス宛てでも、経路はdev loと表示されました。これは経路表上の判断なので、実際のパケットも本当にeth0を通らないのか、tcpdumpでeth0を直接監視しながら接続して確かめました。
実行結果です。
$ tcpdump -i eth0 -n port 22
0 packets captured
0 packets received by filter
0 packets dropped by kernel
ssh sshdemo@172.17.0.4で接続している間、eth0上には1パケットも観測されませんでした。接続前後の/proc/net/devの統計でもloのバイト数・パケット数だけが増え、eth0は変化なし。経路表の宣言・統計の差分・実際のパケットキャプチャの3つが一致したので、eth0自身のIPアドレスに接続しても、実際のパケットはeth0を一切通らずloだけで完結している、と言えます。
ただし、straceのconnect自体はこの違いを教えてくれません。
connect(3, {sa_family=AF_INET, sin_port=htons(22), sin_addr=inet_addr("172.17.0.4")}, 16) = 0
localhostに接続したときと見た目上まったく同じconnectの記録で、「実際はloで処理された」という情報はsyscallレベルには出てきません。この経路のショートカットはip route getやインターフェース統計を見て初めて分かるカーネル内部の判断で、straceだけを見ていても気づけないものでした。
接続の成功と失敗を比較する
connectの返り値を、成功・拒否・無応答の3パターンで比較してみました。
成功時は、先ほど見た通りconnect(...) = 0がそのまま返ってきます。
sshdを止めてポートを閉じた状態(ECONNREFUSED)を試すと、少し違う形で結果が現れました。
実行結果です。
connect(3, {sa_family=AF_INET6, ... "::1" ...}, 28) = -1 EINPROGRESS (Operation now in progress)
getsockopt(3, SOL_SOCKET, SO_ERROR, [ECONNREFUSED], [4]) = 0
socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) = 3
connect(3, {sa_family=AF_INET, ... "127.0.0.1" ...}, 16) = -1 EINPROGRESS (Operation now in progress)
getsockopt(3, SOL_SOCKET, SO_ERROR, [ECONNREFUSED], [4]) = 0
+++ exited with 255 +++
connectの感覚だと、失敗すれば-1とエラー番号がその場で返ってくると考えると思います。実際には、非同期(ノンブロッキング)ソケットのためconnect自体はEINPROGRESS(処理中)を返すだけで、本当の結果は後続のgetsockopt(SOL_SOCKET, SO_ERROR)で初めて判明していました。しかもIPv6(::1)で失敗した後、IPv4(127.0.0.1)でも同じように試し、両方失敗して初めてsshは接続を諦めていました。
実在しないIP(192.0.2.1。RFC 5737で文書用に予約されているアドレスなので、実在するサーバーに迷惑をかける心配がありません)へConnectTimeout=3を指定して接続すると、また違う終わり方をしました。
実行結果です。
connect(3, {sa_family=AF_INET, sin_port=htons(22), sin_addr=inet_addr("192.0.2.1")}, 16) = -1 EINPROGRESS (Operation now in progress)
ppoll([{fd=3, events=POLLIN|POLLOUT}], 1, {tv_sec=3, tv_nsec=0}, NULL, 8) = 0 (Timeout)
+++ exited with 255 +++
ECONNREFUSEDの時と違ってgetsockoptでエラーが判明することはなく、ppollが指定した3秒(ConnectTimeout=3)ぴったりでタイムアウトしていました。ConnectTimeoutオプションが、実体としてはppollの待ち時間としてそのまま渡されていることが確認できました。
暗号化された通信を見る
接続が確立した後は、同じソケットに対してreadとwriteが繰り返されます。
write(3, "-\336\222\355\375\370<\261\357\0\256\2\322\301b\17\2658B&\270Zl\"\377\257\36\367FqLq"..., 36) = 36
ここでSSHの鍵交換や認証、コマンド実行のやり取りが行われていますが、中身は暗号化されているためstraceからはバイト列にしか見えません。ここは深追いせず、「通信の中身は見えないが、通信していること自体はread/writeの回数とサイズで観測できる」ことだけ確認しました。
なお、この間getrandomが1540回呼ばれていました。試しに通信を伴わないssh -Vだけをstrace -cで見てみると、getrandomはわずか3回です。同じコマンド(ssh sshdemo@localhost echo test)を5回実行して比較しても、5回とも寸分違わず1540回でした。つまりこの大量のgetrandomは実際の鍵交換・認証・パケットのやり取りの中で発生していて、しかも呼ばれる「回数」自体はこのセッションの構成(鍵交換アルゴリズムやパケット数)によって決まる固定値になっているようです。
RFC 4253(SSH Transport Layer Protocol)のBinary Packet Protocolには、各パケットのパディングについてこう書かれています。
Arbitrary-length padding, such that the total length of (packet_length || padding_length || payload || random padding) is a multiple of the cipher block size or 8, whichever is larger. There MUST be at least four bytes of padding. The padding SHOULD consist of random bytes.
出典: RFC 4253 - The Secure Shell (SSH) Transport Layer Protocol, Section 6
パケットごとに最低4バイトのランダムなパディングが必須(SHOULD)なので、やり取りするパケット数が多いほどgetrandomの回数も増えると考えられますが、1540回すべての内訳までは今回追い切れていません。
秘密鍵を探す動きを見る
最後に、使い捨ての秘密鍵をリネームしてから接続してみました。
mv ~/.ssh/id_ed25519 ~/.ssh/id_ed25519.bak
strace -f -e trace=openat ssh -o BatchMode=yes sshdemo@localhost echo x
実行結果です。
openat(AT_FDCWD, "/home/sshdemo/.ssh/id_ed25519", O_RDONLY) = -1 ENOENT (No such file or directory)
$ ssh -o BatchMode=yes sshdemo@localhost echo x
sshdemo@localhost: Permission denied (publickey,password).
「sshは秘密鍵を探している」というのは比喩ではなく、実際にファイルシステムへのopenatとして観測できました。鍵が無ければENOENT、その後に認証全体が失敗する、という一連の流れが1つのログの中に収まっています。
接続を終える
正常に終了する場合は、最後にこう記録されていました。
write(3, "\370\236\317\36y\265\20:\300\\QY7\3408\234;\20Y\254$T\31\22t\267\\\336\376\17\311\260"..., 60) = 60
close(3) = 0
munmap(0xe31307656000, 266240) = 0
exit_group(0) = ?
+++ exited with 0 +++
最後のwriteでおそらく切断のフレームを送り、ソケットをcloseし、exit_groupでプロセスが終了します。ssh user@serverという1行のコマンドは、このexecveからexit_groupまでの間に発行される数百〜数千のシステムコールとして、Linuxカーネルから見えていました。
まとめ
ssh sshdemo@localhostという1つのコマンドをexecveからexit_groupまで追うと、connectまわりだけで2つのことが分かりました。非同期のconnectはEINPROGRESSを返すだけで、本当の結果(成功したのか、ECONNREFUSEDだったのか)はgetsockoptやppollという別のタイミングのシステムコールで判明する、という2段構えの仕組みになっていること。そしてUDPソケットへのconnectが、パケットを送らずに経路情報だけを尋ねるために使われている場面があったことです。
今回は基本的な流れに絞りましたが、strace -e trace=%fileや%networkによるフィルタ、-cによるシステムコールの統計はまだ試せていません。-Tは各システムコールの実行時間を計測するオプションで、どのsyscallに時間がかかっているかを特定できます。今回getrandomが1540回も呼ばれていることが分かったので、これが本当に無視できるくらい速いのか、それとも意外と時間がかかっているのか、-Tで実際に計測してみたいです。次回はこのあたりをもう少し細かく覗いてみたいと思います。
普段ブラックボックスになりがちな「アプリケーションとOSの境界」ですが、straceを使うと、思っていたより多くのことが観測できると感じました。SSH以外のコマンドでも気になるものがあれば、皆さんも一度覗いてみてはいかがでしょうか。
参考
- strace(1) - Linux manual page
- ssh(1) - OpenBSD manual page
- ssh_config(5) - OpenBSD manual page
- sshd_config(5) - OpenBSD manual page
- RFC 4253 - The Secure Shell (SSH) Transport Layer Protocol, Section 6
- RFC 5737 - IPv4 Address Blocks Reserved for Documentation
- Docker overview - Docker Docs
- OpenSSH Release Notes
- nss/getaddrinfo.c - glibc公式リポジトリ






