4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

OpenSSL 3系で遭遇したTLS例外のケーススタディ

Last updated at Posted at 2025-11-30

ジョブカン事業部のアドベントカレンダー、シリーズ2の1日目の記事です。

ジョブカン採用管理で開発を担当しています、@maeda3net です。

趣味の開発でDebian 11(Bullseye)から 12(Bookworm)へ更新した際、特定ホストへの通信だけ TLS が例外を吐いて失敗するという奇妙な事象に遭遇しました。
本記事では、その原因を特定する過程と、再現環境を構築して詰めていった手順を紹介します。

問題の概要

Bookwormでは OpenSSLのバージョンが従来の1系から3系に更新されています。
Debianバージョン更新後、Rubyアプリケーションから特定の外部サイトにアクセスすると、以下のような例外が確定で発生するようになりました。

/usr/local/lib/ruby/3.3.0/openssl/buffering.rb:211:in `sysread_nonblock': SSL_read: unexpected eof while reading (OpenSSL::SSL::SSLError)
        from /usr/local/lib/ruby/3.3.0/openssl/buffering.rb:211:in `read_nonblock'
        from /usr/local/lib/ruby/3.3.0/net/protocol.rb:218:in `rbuf_fill'
        from /usr/local/lib/ruby/3.3.0/net/protocol.rb:185:in `read_all'
        from /usr/local/lib/ruby/3.3.0/net/http/response.rb:611:in `block in read_body_0'
        from /usr/local/lib/ruby/3.3.0/net/http/response.rb:588:in `inflater'
        from /usr/local/lib/ruby/3.3.0/net/http/response.rb:593:in `read_body_0'
        from /usr/local/lib/ruby/3.3.0/net/http/response.rb:363:in `read_body'
        from /usr/local/lib/ruby/3.3.0/net/http.rb:1918:in `block in get'
        from /usr/local/lib/ruby/3.3.0/net/http.rb:2353:in `block in transport_request'
        from /usr/local/lib/ruby/3.3.0/net/http/response.rb:320:in `reading_body'
        from /usr/local/lib/ruby/3.3.0/net/http.rb:2352:in `transport_request'
        from /usr/local/lib/ruby/3.3.0/net/http.rb:2306:in `request'
        from /usr/local/lib/ruby/3.3.0/net/http.rb:2299:in `block in request'
        from /usr/local/lib/ruby/3.3.0/net/http.rb:1570:in `start'
        from /usr/local/lib/ruby/3.3.0/net/http.rb:2297:in `request'
        from /usr/local/lib/ruby/3.3.0/net/http.rb:1917:in `get'

同じコードがBullseyeでは正常に動作していたため、OpenSSL 3系特有の挙動を疑い調査を進めました。

なお、発生当時の環境はざっくり以下の通りです。

環境 バージョン
Debian Bullseye、Bookworm
OpenSSL 1系、3系

原因

調査の結果、OpenSSL 3.0ではTLSの終了処理である close_notify の扱いが厳格化されていました。

OpenSSL 挙動
1.x サーバが close_notify を送らずに TCP レベルで接続を切断しても、ある程度許容されていた
3.x RFC に沿って、TLSレベルの正常終了( close_notify )なしの切断をエラーと扱うようになった

参考: https://github.com/openssl/openssl/discussions/22690

これは安全性の観点では正しい挙動ですが、昔ながらの実装を持つサーバでは close_notify を送らず切断してしまうケースがあるようです。
今回遭遇したケースでは、以下の条件が揃ったことで例外が確定で発生していました。

  1. クライアント側OpenSSLバージョンが3系
  2. サーバが TLS の close_notify を送らずに切断する
  3. HTTP レスポンスに Content-Length が含まれない(転送終了をソケット切断で判断する必要が生じるため)

特に 3. が重要で、Content-Length があるとHTTP クライアントは切断に頼らずボディ長を判断できるためか、例外は起きませんでした。

再現環境の構築

手元で発生している原因が前述の問題と同様と推測できるため、ローカル環境で再現できるサーバを書いて確かめることにしました。

最終的に、以下の構成で再現できるシンプルな環境を作りました。

  • OpenSSL 3.x を使うクライアント(Debian Bullseye, Bookworm)
  • close_notify を送らずに切断する TLS サーバ(Ruby + OpenSSL)
  • Content-Length を明示しない HTTP レスポンス

再現用コード

サーバ実装

TLSの終了処理を踏まずに切断しつつ、Content-Length を明示しないことで、クライアントがEOFで終了を判断する状況を作りました。

require 'socket'
require 'openssl'

PORT_NUM = 8443

tcp_server = TCPServer.new('127.0.0.1', PORT_NUM)

# openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 1 -nodes -subj "/CN=localhost"
context = OpenSSL::SSL::SSLContext.new
context.cert = OpenSSL::X509::Certificate.new(File.read('cert.pem'))
context.key = OpenSSL::PKey::RSA.new(File.read('key.pem'))
ssl_server = OpenSSL::SSL::SSLServer.new(tcp_server, context)

puts "server is listening at port #{PORT_NUM}"

loop do
  ssl_socket = ssl_server.accept

  begin
    response_body = "Hello World"
    response = <<~HTTP
      HTTP/1.1 200 OK\r
      Content-Type: text/html\r
      Connection: close\r
      \r
      #{response_body}
    HTTP

    ssl_socket.write(response)
    ssl_socket.flush

    ssl_socket.io.close  # TLSの終了処理を踏まずに切断
  rescue => e
    puts "サーバーエラー: #{e.message}"
  ensure
    ssl_socket.close rescue nil
  end
end

クライアント実装

BookwormとBullseyeのDockerコンテナ上でそれぞれクライアント実装を動作させることで、Bullseyeでは成功するがBookwormでは例外発生という状況を再現しました。

require 'net/http'
require 'openssl'

uri = URI('https://host.docker.internal:8443/')
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

# 自己署名許可
http.verify_mode = OpenSSL::SSL::VERIFY_NONE

begin
  response = http.get('/')
  puts "Response: #{response.code} #{response.body}"
rescue => e
  puts "Exception: #{e.class} - #{e.message}"
end

再現で悩んだ話

上記の再現コードはかなりシンプルに構成できたものですが、そこに至るまでにかなり試行錯誤を繰り返しました。

再現に取り掛かった当初は、条件として Content-Length を明示しないという点に気づいていなかったため、レスポンスに癖で Content-Length をつけて再現コードを作っていました。
しかし、これでは先に述べた再現の条件を満たさないので当然再現はしません。
close_notify を送らずにTCP接続を閉じてもBullseye、Bookwormともに例外が発生せず正常終了してしまいます。

不正な Content-Length を送れば再現するのでは?とトライしましたが、これだと今度はBullseye、Bookworm双方でエラーが発生してしまいます。
これは当然で、不正な Content-Length はHTTPプロトコルにおける不正でありOpenSSLバージョンに関係ないためです。

ここで途方に暮れてLLMに相談もしましたが、 Content-Length を不正な値にする例しか出してくれませんでした。

振り出しに戻り、改めてHTTPリクエストレベルでの見直しをし始めたところでようやく Content-Length はレスポンスとして必須だったか?と気づきを得てHTTPメッセージボディの仕様を確認し直しました。

最終的に RFC9112 6.3 に辿り着き、 Content-Length がない場合はサーバー接続が閉じるまでの受信オクテット数をボディ長とするといった記述を見つけ解決しました。
実際に Content-Length ヘッダーを抜くことで、Bullseyeでは発生せずBookwormで発生し、ついに再現ができました。

まとめ

問題の再現を通して、検証だけでなく、その裏にあるHTTPの仕様・OpenSSLの挙動・EOFの扱いといった複数のレイヤーを深く理解する機会になりました。
再現環境を作ることは面倒に感じることもありますが、問題を解決する上でもっとも確実で、もっとも学びが深い方法だと考えています。

同じように問題に遭遇した際は、ぜひ小さい再現環境を作ってみてください。
きっと多くの学びを得られ、問題解決の大きな手助けとなるはずです。

参考資料

調査・再現にあたり以下を参考にさせていただきました。

お知らせ

DONUTSでは新卒中途問わず積極的に採用活動を行っています。
我々ジョブカン事業部も、一緒に働くエンジニアを募集しています。

4
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?