AspNetCore.Mvcの重大な脆弱性を発見し、独自のシリアル化に切り替えた方法

こんにちは、Habr!



この記事では、パフォーマンスを最適化し、AspNetCore.Mvcの機能を調査した経験を共有したいと思います。







背景



数年前、ロードされたサービスの1つで、CPUリソースの大幅な消費に気付きました。 サービスのタスクは実際にメッセージを取得してキューに入れることであり、検証、データ追加などの操作を以前に実行していたため、奇妙に見えました



プロファイリングの結果、デシリアライズはほとんどのプロセッサー時間を「消費」することがわかりました。 私たちは標準のシリアライザーを捨てて、Jilで独自のシリアライザーを作成しました。その結果、リソース消費が数倍減少しました。 すべてが正常に機能し、それを忘れることができました。



問題



セキュリティ、パフォーマンス、フォールトトレランスを含むすべての領域でサービスを継続的に改善しているため、「セキュリティチーム」はサービスのさまざまなテストを実施しています。 そして、しばらく前に、ログのエラーに関するアラートが「飛びます」-どういうわけか、無効なメッセージをさらに逃しました。



詳細な分析では、すべてがかなり奇妙に見えました。 リクエストモデルがあります(ここでは、簡単なコード例を示します)。



public class RequestModel { public string Key { get; set; } FromBody] Required] public PostRequestModelBody Body { get; set; } } public class PostRequestModelBody { [Required] [MinLength(1)] public IEnumerable<long> ItemIds { get; set; } }
      
      





アクションPostを持つコントローラーがあります。例えば:



 [Route("api/[controller]")] public class HomeController : Controller { [HttpPost] public async Task<ActionResult> Post(RequestModel request) { if (this.ModelState.IsValid) { return this.Ok(); } return this.BadRequest(); } }
      
      





すべてが論理的なようです。 リクエストがボディビューから送信された場合



 {"itemIds":["","","" … ] }
      
      





APIはBadRequestを返します。これにはテストがあります。



それにもかかわらず、ログでは逆のことがわかります。 ログからメッセージを取得してAPIに送信し、ステータスOK ...および...ログの新しいエラーを取得しました。 私たちの目を信じないで、私たちは間違いを犯し、はい、確かにModelState.IsValid == trueであることを確認しました。 同時に、約500ミリ秒の異常に長いクエリ実行時間に気付きましたが、通常の応答時間は50ミリ秒を超えることはほとんどなく、これは1秒あたり数千のリクエストに対応します。



このリクエストとテストの違いは、リクエストに600以上の空行が含まれていることだけでした...



その後、多くのbukafコードがあります。



理由



彼らは何が悪いのか理解し始めました。 エラーを排除するために、彼らはクリーンなアプリケーションを作成し(そこから例を挙げました)、それを使用して説明した状況を再現しました。 合計で、AspNetCore.Mvcコードの調査、テスト、メンタルデバッグに数日間を費やし、問題がJsonInputFormatterにあることが判明しました



JsonInputFormatterは、JsonSerializerを使用します。JsonSerializerは、逆シリアル化および型のストリームを取得し、配列(この配列のすべての要素)である場合、各プロパティのシリアル化を試みます。 同時に、シリアル化中にエラーが発生した場合、JsonInputFormatterは各エラーとそのパスを保存し、処理済みとしてマークします。これにより、さらに逆シリアル化の試行を続けることができます。



JsonInputFormatterエラーハンドラーのコードを以下に示します(上記のリンクのGithubで入手できます)。



 void ErrorHandler(object sender, Newtonsoft.Json.Serialization.ErrorEventArgs eventArgs) { successful = false; // When ErrorContext.Path does not include ErrorContext.Member, add Member to form full path. var path = eventArgs.ErrorContext.Path; var member = eventArgs.ErrorContext.Member?.ToString(); var addMember = !string.IsNullOrEmpty(member); if (addMember) { // Path.Member case (path.Length < member.Length) needs no further checks. if (path.Length == member.Length) { // Add Member in Path.Memb case but not for Path.Path. addMember = !string.Equals(path, member, StringComparison.Ordinal); } else if (path.Length > member.Length) { // Finally, check whether Path already ends with Member. if (member[0] == '[') { addMember = !path.EndsWith(member, StringComparison.Ordinal); } else { addMember = !path.EndsWith("." + member, StringComparison.Ordinal); } } } if (addMember) { path = ModelNames.CreatePropertyModelName(path, member); } // Handle path combinations such as ""+"Property", "Parent"+"Property", or "Parent"+"[12]". var key = ModelNames.CreatePropertyModelName(context.ModelName, path); exception = eventArgs.ErrorContext.Error; var metadata = GetPathMetadata(context.Metadata, path); var modelStateException = WrapExceptionForModelState(exception); context.ModelState.TryAddModelError(key, modelStateException, metadata); _logger.JsonInputException(exception); // Error must always be marked as handled // Failure to do so can cause the exception to be rethrown at every recursive level and // overflow the stack for x64 CLR processes eventArgs.ErrorContext.Handled = true; }
      
      





プロセッサの最後でエラーを処理済みとしてマークします



 eventArgs.ErrorContext.Handled = true;
      
      







したがって、すべての逆シリアル化エラーとそれらへのパスを出力するための機能が実装されており、それらがどのフィールド/要素にあったか、...ほとんどすべて...



実際、JsonSerializerには200個のエラーの制限があり、その後クラッシュしますが、すべてのコードがクラッシュし、ModelStateが...有効になります!...エラーも失われます。



解決策



ためらうことなく、Jil Deserializerを使用してAsp.Net CoreのJsonフォーマッターを実装しました。 ボディのエラーの数は私たちにとって絶対に重要ではないので、それらの存在の事実のみが重要であり(そして、それが本当に役立つ状況を想像することは一般に困難です)、シリアライザーコードは非常に簡単であることが判明しました。



カスタムJilJsonInputFormatterのメインコードを提供します。



 using (var reader = context.ReaderFactory(request.Body, encoding)) { try { var result = JSON.Deserialize( reader: reader, type: context.ModelType, options: this.jilOptions); if (result == null && !context.TreatEmptyInputAsDefaultValue) { return await InputFormatterResult.NoValueAsync(); } else { return await InputFormatterResult.SuccessAsync(result); } } catch { // -   } return await InputFormatterResult.FailureAsync(); }
      
      





注意! Jilは大文字と小文字を区別します。つまり、ボディのコンテンツは



 {"ItemIds":["","","" … ] }
      
      





そして



 {"itemIds":["","","" … ] }
      
      





同じではありません。 最初のケースでは、camelCaseが使用されている場合、逆シリアル化後にItemIdsプロパティはnullになります。



しかし、これは私たちのAPIであるため、それを使用および制御します。私たちにとっては重要ではありません。 パブリックAPIであり、誰かがそれを呼び出して、キャメルケースではなくパラメーター名を渡す場合、問題が発生する可能性があります。



結果



結果は予想をはるかに上回りました。予想通り、APIはリクエストされたリクエストにBadRequestを返し始め、それを非常に迅速に行いました。 以下に、展開の前後の応答時間とCPUの変化を明確に示すグラフのスクリーンショットを示します。

リードタイムの​​リクエスト:



画像



16:00頃に展開がありました。 展開前のp99の実行時間は30〜57ミリ秒でしたが、展開後は9〜15ミリ秒になりました。 (18:00のピークの繰り返しは無視できます-これは別の展開でした)



CPUグラフは次のように変更されました。



画像



このため、執筆時点でGithubに問題を持ち込み、マイルストーン3.0.0-preview3でバグとしてフラグを立てました。



結論として



問題が解決するまで、特にパブリックAPIがある場合は、標準のデシリアライゼーションの使用を放棄する方が良いと考えています。 この問題を知っている攻撃者は、誤った配列を大きくするほど、Bodyが多くなるほど、JsonInputFormatterでの処理が長くなるため、同様の無効なリクエストを大量にスローすることで簡単にサービスを投入できます。



Artyom Astashkin、開発チームリーダー



All Articles