
このドキュメントとリポジトリは、OpenSSLにおけるpunycodeバッファオーバーフローの問題であるCVE‑2022‑3602の解説です。これは「アンチPoC」であり(この問題は悪用可能ではないように見えます)、独自にOpenSSLをビルドしている方やコンパイラメンテナ向けの内容です。
同じリリースには別のCVE(CVE‑2022‑3786)もあり、これもバッファオーバーフローを引き起こしますが、攻撃者はその場合コンテンツを制御できません。その問題の再現コードはここにはありませんが、クラッシュによるサービス拒否(DoS)を引き起こす可能性があります。
クラッシュやバッファオーバーフローは決して良いものではなく、OpenSSL 3.0.x を使用しているなら、早急にアップデートするのが賢明です。
誤りや欠落があれば、GitHubのIssueまたはPull Requestで報告してください。
ossl_punycode_decode がpunycodeデコードを処理する際に、1バイトのずれ(off‑by‑one)があり、結果として4バイトのオーバーフローが発生します。この問題は、OpenSSLが証明書チェーンを処理するときにのみ顕在化し、2つの条件が必要です。第一に、チェーン内のCAまたは中間証明書がpunycodeを使用する名前制約フィールドを含んでいること。
nameConstraints = permitted;email:xn-maccrthaigh-n7a.com
第二に、リーフ証明書がSmtpUTF8Mailbox文字列を指定するSubjectAlternateName(SAN)otherNameフィールドを含んでいること。
otherName = 1.3.6.1.5.5.7.8.9;UTF8:[email protected]
この条件がトリガーされると、nameConstraintsフィールド内のpunycode(otherNameフィールド内のpunycodeではない)が脆弱なOpenSSLのpunycode解析によって処理されます。
David Benjamin氏とMatt Caswell氏により、nameConstraintのチェックは通常の証明書チェーン検証と署名検証の後に行われることが判明しています。ほとんどのアプリケーションでは、自己署名証明書や無効なチェーンではこの問題はトリガーできません。
なお、opensslの s_client や s_server アプリケーションはデバッグ用であり、チェーンが無効でも処理を停止しません。
信頼されたCAまたは中間証明書に悪意のあるペイロードが含まれている必要があり、さらに問題をトリガーするリーフ証明書に署名している必要があります。
信頼されていない当事者がCAまたは中間証明書となる環境(例えば、顧客提供のプライベートCAをサポートするホスティングサービス)もあるかもしれませんが、これは一般的ではありません。
多くのアプリケーションでは「いいえ」という答えになります。なぜなら、コンパイラがスタックをどのようにレイアウトするか、またスタックカナリア/スタッククッキー、パディング、PIE、FORTIFY_SOURCEなどの他の保護機構が存在するからです。
この問題はスタック上に32ビットのオーバーフローを引き起こします。これはシェルコードを直接実行するには十分ではありませんが、アプリケーションの制御フローを変更するには十分かもしれません。例えば、X509証明書チェーンに埋め込まれたシェルコードにジャンプすることが、そのデータ(またはその断片)がスタック上にコピーされ、かつ実行可能な場所にある場合には可能かもしれません。
私がテストしたすべてのLinuxプラットフォームでは、オーバーフローはパディングに発生し、無害でした。理論的には、コンパイラが変数をレイアウトする方法によっては、オーバーフローが ossl_a2ulabel 関数内の他の変数に発生する可能性があります。
インライン展開に依存しますが、存在する変数の完全なリストは次のとおりです。
outptr, inptr, size, result, tmpptr, delta, seed, utfsize
これらのいずれも、特権昇格や興味深い制御への明白な経路を提供するものではありません。
再現と、4バイトすべてを可能な限り制御したオーバーフローを作成するためのツールを含むtarballを添付しました。参照用の再現文字列(xn--ww90271...aaaa)は、0xFF 0x0F 0x0F 0x0F の値で4バイトをオーバーフローさせます。これでアプリケーションがクラッシュしない場合、そのアプリケーションは脆弱ではない可能性が高いです。
シェルスクリプト run-poc を使用して、悪意のある証明書チェーンを生成できます。悪意のあるCA証明書は ca.cnf から生成され、トリガーとなるリーフ証明書は leaf.cnf から生成されます。
CA証明書は以下の参照ペイロードを使用します。
xn--ww902716aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Pythonスクリプトを使用して、異なるペイロードに対する他のpunycode文字列を生成できます。
run-poc を実行すると、opensslクライアントとサーバーが実行され、問題の悪用を10回試みます。
脆弱なOpenSSLはおそらくクラッシュします。ただし、スタックカナリアやスタッククッキーの保護により、通常は(より安全な)アプリケーションクラッシュが発生するため、OpenSSLのバージョンがRCEに対して脆弱であるとは限りません。また、これは同じリリースの別のCVEの深刻度を変更するものではありません。
4バイトのオーバーフローをほぼ完全に制御するのは驚くほど微妙であり、OpenSSLのpunycodeデコーダを非標準/無効なpunycodeで悪用する必要があります。添付のtarballには、その微妙な点を処理する文字列を構築するスクリプトが含まれています。以下では、その仕組みを説明します。
セキュリティ問題は ossl_punycode_decode() にあります。
int ossl_punycode_decode(const char *pEncoded, const size_t enc_len,
unsigned int *pDecoded, unsigned int *pout_length)
ossl_punycode_decode は ossl_a2ulabel から呼び出されます。pEncoded バッファは、X509証明書チェーンからのほぼ任意のサイズのバッファです。これはnameConstraintフィールド内の "xn--" の後に続く部分です。そのような証明書チェーンを再現する方法については、[再現] を参照してください。
pDecoded は LABEL_BUF_SIZE サイズの unsigned int の配列です。LABEL_BUF_SIZE は512であり、ほとんどのプラットフォームで unsigned int は4バイト幅です。したがって、ほとんどのプラットフォームで pDecoded は2048バイトの長さです。
ossl_punycode_decode() 内部で、問題の核心は以下の誤った長さチェックです。
if (written_out > max_out)
max_out は *pout_length に対応し、常に512です。そして written_out は pDecoded に書き込まれた unsigned int の数を追跡します。written_out は書き込みの後にのみインクリメントされるため、この誤ったチェックにより、pDecoded に513個の unsigned int が書き込まれる可能性があります。最終結果は次のようになります...
pDecoded = [ ... , 'X' , 'Y' , 'Z' ] 'P'
// インデックス 509 510 511
ここで、Cの慣例に従い、インデックスは0始まりなので、スロット番号511は配列の512番目の要素です。'P' は、ossl_a2ulabel() 内の buf バッファ(pDecoded が指す先)のためにスタックに割り当てられた領域を超えた、範囲外に配置された4バイトのペイロードです。
4バイトは小さなオーバーフローであり、nopスレッドを運ぶには不十分ですが、アプリケーションの制御フローを変更するには十分です。例えば、x509証明書チェーンに埋め込まれたシェルコードにジャンプすることが、このデータ(またはその断片)の保存方法とそのメモリが実行可能かどうかに依存して可能かもしれません。しかし、攻撃者にとっては依然として困難があります。
まず、コンパイラによるスタックのパディングとアライメント、あるいはスタックカナリアなどの防御により、悪用が完全に不可能になる可能性があります。
第二に、ossl_punycode_decode() へのパスは1つしかなく、そのパスはスタック上のバッファを使用します。これにより、異なるメモリ位置で同時に4バイトのオーバーフローが発生する可能性は低くなります。
Punycode文字列には基本的に2つの形式があります。1つは xn--c1yn36f(點看)で、もう1つは xn--maccrthaigh-n7a(maccárthaigh)です。最後の - 区切りの後の部分は、基本ASCIIではない任意のUnicodeコードポイントと、それらを挿入する文字列内の位置をエンコードした36進のブートストリングです。ここで重要なのは、ossl_punycode_decode() のデコードプロセスで2つの値が生成されることです。1つは 'n' で、挿入される unsigned int のコードポイント値、もう1つは 'i' で、バッファ内で挿入する位置です。
書き込みは2つの方法で発生します。i が文字列の途中にある場合、memmove() が最初に「スペースを作る」ために右側のすべてを1スロット分コピーします。
memmove(pDecoded + i + 1, pDecoded + i,
(written_out - i) * sizeof *pDecoded);
そして、作成したスペースに n を書き込みます:
pDecoded[i] = n;
i が文字列の末尾にある場合、memmove() の最終パラメータが0になるため効果はありません。もう一方の行は単純な追加になります。
それでは、ペイロード 'P' をオーバーフロー位置に配置する3つの異なる方法と、制約が生じる理由を見ていきます。
オーバーフローをトリガーする最も簡単な方法は、511個のASCII文字と2個の非ASCII文字を含むpunycode文字列を作成することです。例えば、"ÁÁAAAAAAAA...AAA" のような513文字の文字列のpunycodeエンコードで実現できます。この場合、written_out が510のときにバッファは次のように配置されます。
pDecoded = [ 'A' , 'A' , ... , 'A' , 'A', ]
// インデックス 0 1 ... 509 510 511
これはコピーされた基本ASCII文字だけです。次にpunycodeブートストリングを解析し、位置0に 'Á' を挿入します。ただし、位置は0から511の間の任意の場所にできます。
pDecoded = [ 'Á' , 'A' , ... , 'A' , 'A', 'A' ]
// インデックス 0 1 ... 509 510 511
これを繰り返します。
pDecoded = [ 'Á' , 'Á' , ... , 'A' , 'A', 'A' ] 'A'
// インデックス 0 1 ... 509 510 511 512
これにより、通常のASCII 'A' が「移動」されてオーバーフローします。この場合の4バイトペイロードは 0x00 0x00 0x00 0x41 になります。後でわかるように、punycodeの仕組み上、最終バイトがASCII範囲にある値を表現できるのはこの方法だけです。
2つの非ASCII文字が必要なのは、基本文字数に対する正しい境界チェックがあるためで、512未満でなければなりません。
さらに、最終バイト値が46にならないという制約もあります。これは ossl_punycode_decode() がリテラルの . 文字より前の文字列部分に対して呼び出されるためです。Punycodeはドメインラベル用であり、ドットを含めることはできません。
オーバーフローをトリガーする次に簡単な方法は、末尾に非ASCII文字を持つ513文字の文字列を作成することです。例えば "AAAAAAAAAA...AAÁ" です。この場合、最後の2ステップでは次のようになります。
pDecoded = [ 'A' , 'A' , ... , 'A' , 'A' ] // インデックス 0 1 ... 510 511
そして
pDecoded = [ 'A' , 'A' , ... , 'A' , 'A' ] 'Á'
// インデックス 0 1 ... 510 511
非ASCII文字が直接オーバーフロー位置に入ります。OpenSSLのpunycodeパーサーは、ここでのオーバーフロー値が実際に有効なUnicode文字であることを強制しません。これはほぼバイナリデコードプロセスです。しかし、punycodeデコードの微妙な点により、方法2は最初に思われるほど柔軟ではありません。
Punycodeでは、値 n と i は両方とも単一の可変長整数としてエンコードされ、それがbase36でASCIIエンコードされます。2つの無関係な数を1つの整数としてエンコードすることは不可能に思えるかもしれませんが、punycodeの巧妙なトリックは、文字列の(これまでの)長さを隠しフィールドとして使用することです。
例えば、4つの基本文字と1つの非基本文字を持つpunycode文字列 AAÁAA を考えます。これはまず基本文字だけとして表現されます... AAAA。'Á' のUnicode値は225で、文字列内の位置は2です。トリックは、値に長さプラス1を乗算し、位置を加算することです。つまり ((225 * (4 +1)) + 2) = 1127 となり、それが(可変長base36で)エンコードされます。
デコードするには逆のことをします。1127 / 5 = 225、1127 % 5 = 2。これで1つの数から2つの数が復元されます。しかし、文字列が長くなるほど、値の大きさに対する制約が強くなります。そうしないと、乗算結果が符号なし整数に収まらなくなるからです。一般的に、文字列の長さがMの場合、値の幅からlog Mビットが失われます。
512番目の整数を扱う時点で、9ビットの幅が失われます。方法2を使用すると、一見32ビットのペイロードの最大値は実際には2^23になります。3バイト全体にも満たないです。方法2は最適ではありません。
4バイトの制御を取り戻すための最も効率的な方法は、ペイロード文字を何度も繰り返すことです。これまでのところ、punycodeの処理に関する他の2つの関連する詳細を省略してきました。
最初の詳細は、非ASCII文字は文字列の順序ではなく、値の昇順でエンコードされるということです。文字列 "ÉÁ" は、"Á at position 1, É at position 0" としてエンコードされます。なぜなら、Á (225) は É (233) よりも値が小さいからです。
2番目の詳細は、非ASCII文字はリテラル値としてではなく、最後にデコードされた値からのデルタとしてエンコードされるということです。最初の値には相対する前の値がないため、ハードコードされた開始点として128が使用されます。
これらの微妙な点により、punycodeは非常にスペース効率が良くなりますが、同時に非ASCII文字は128未満の値にデコードできないことを意味します。最小のデルタは0であり、負のデルタを表現する方法はありません。したがって、128未満の数値が必要な場合は方法1を使用する必要があります。
また、ペイロードを可能な限り制御するための最良の戦略は、ペイロードを完全な文字列内の唯一の値にすることです。これにより、エンコードの0番目の位置から最大の幅を得られます。エンコードされる文字列は次のようになります。
[ 'P', 'P', ... 'P', 'P', 'P' ]
0 1 510 511 512
これはOpenSSLによって次のようにデコードされます。
pDecoded = [ 'P', 'P', ... 'P', 'P' ] 'P'
0 1 510 511 512
P がオーバーフロー位置にあり、128から (2^32 - 1) までの任意の値を表現できます。
これらすべてには非標準のpunycodeエンコーダが必要であり、必要に応じて方法1または方法3を使用してペイロードを作成できるスクリプトを含めました。
OpenSSLの更新以外に、他の緩和策はありますか?
証明書チェーンはほとんどの環境で平文で渡され、悪意のあるチェーンは、SubjectAlternateName の OtherName フィールドにDERエンコードされた 1.3.6.1.5.5.7.8.9 NIDを含むTCP接続を拒否することでブロックできる可能性があります。
残念ながら、このフィールドは2つ以上のパケットに任意に分割される可能性があり、ブロックするには何らかのステートフルパターンマッチャーが必要です。証明書は圧縮することもできますが、現在OpenSSL 3.0.xは証明書圧縮をサポートしていません。
さらに、TLS 1.3ではクライアント証明書チェーンはワイヤ上で暗号化され、以前のバージョンのTLSでは、既存の接続を再ネゴシエートするときに暗号化された証明書チェーンをサポートしています。これはサーバー開始の証明書認証で行われることがあります。そのような場合、ネットワークフィルタは効果がありません。
静的リンクされたバイナリでopenssl 3を使用しているかどうかを確認するには?
readelf -a [binary] | grep -i ossl_punycode_decode
これにより、静的リンクされたバイナリ内の脆弱な関数を検索できます。この関数を含むのはOpenSSL >= 3.0のみです。