自己紹介
初めまして。
仕事で作っているものが頭を使わなくて脳が暇しているので、趣味の方で何か作ろうとしている人です。
ものを作るにあたって、Twitterでは短いし連投で繋げるのもなんだと思って別のブログも作ったのですが、直接的な技術情報に関しては私自身がQiitaを優先的に参照していたしということで、アカウントを作りました。
今回の経緯
.NETを使用して現在作成しているプログラムで、独自の例外を作るためにMicrosoft DocsでException クラス等を漁っていた訳ですが、ISerializable.GetObjectData(SerializationInfo, StreamingContext) メソッドに見慣れない記述を発見しました。
[System.Security.SecurityCritical]
SecurityCritical…?
この記事によると、.NET Framework 4.0以降ではGetObjectDataを使うなということらしいです。
最後に仕事で使ったのは.NET Framework 2.0で、趣味でも.NET Framework 3.5だったので、この辺に関する認識が全く更新されていませんでした。
ISerializableの問題
多分、正しい組み方をしている分には早々問題は発生しないのだとは思いますが、引数のSerializationInfoを使用して組むと何となく思うのは
- 継承関係のどこかに条件分岐で同じ名前が発生すると、SerializationInfo.AddValueでSerializationExceptionが発生する。(逆に言えば、名前が重複して同クラスのコード内で意図しない書き換えが発生する訳ではない)
- ISerializableそのものというよりは、その対になる同じ引数の逆シリアル化コンストラクタに変なものを突っ込まれてセキュリティ攻撃を受ける可能性がある。
とか辺りでしょうか。
解決方法
同じ記事内に書いてあります。
ISafeSerializationData インターフェイスを使ってねという話ですが、これに関する詳しい情報を探そうと名前で検索すると、海外のページとかそれを機械翻訳しただけっぽい日本語の怪しいページばかり。
公式の記事に関しても、素のISafeSerializationDataでサンプルを書いて欲しいところを、よく継承される関係かイベントという付加情報の付いたExceptionの例を出しているので、それ以外のものをシリアル化するとなると理解にもうワンパス掛かる訳です。
コード全体
using System;
using System.Runtime.Serialization;
namespace Test
{
/// <summary>
/// メイン型。
/// </summary>
[Serializable]
public class MainClass
{
[NonSerialized]
private MainClassData data = new MainClassData();
public MainClass(int datum)
{
data.Datum = datum;
}
public int Datum
{
get { return data.Datum; }
}
/// <summary>
/// <see cref="MainClass"/>のデータ部分。
/// </summary>
[Serializable]
private struct MainClassData : ISafeSerializationData
{
private int datum;
public int Datum
{
get { return datum; }
set { datum = value; }
}
void ISafeSerializationData.CompleteDeserialization(object deserialized)
{
MainClass parent = deserialized as MainClass;
parent.data = this;
}
}
}
}
解説
外から操作するMainClassをメイン型、データを格納しているMainClassDataをデータ型と呼ぶことにします。
シリアル化したいデータを格納する型を作る
メイン型の定義内にデータ型の定義をprivateで用意します。
両方に[Serializable]を適用し、データ型にはISafeSerializationDataを実装させておきます。
[Serializable]
public class MainClass
...
[Serializable]
private struct MainClassData : ISafeSerializationData
メイン型の方には[NonSerialized]なデータ型をメンバとして置く
そのままシリアル化するとメンバとして残ってしまうのが都合が悪そうなので、[NonSerialized]を付けます。
[NonSerialized]
private MainClassData data = new MainClassData();
CompleteDeserialization
データ型の逆シリアル化が完了するとCompleteDeserializationの引数でメイン型が渡されてくるので、先ほど[NonSerialized]を付けたデータ型メンバ(サンプルではdata)に、データ型の側から自分を設定させます。
void ISafeSerializationData.CompleteDeserialization(object deserialized)
{
MainClass parent = deserialized as MainClass;
parent.data = this;
}
後は、データ型の方から必要なものを取得したり設定したり
thisの代わりに[NonSerialized]を付けたデータ型メンバ(サンプルではdata)を使えば、何となく同じ感じになりますね。
Exception.SerializeObjectState イベントとSafeSerializationEventArgs クラス(2020/08/05追記)
…で、例として頻出してくるException.SerializeObjectState イベントとの関連付けは何かというのが問題です。
コンストラクタ中でイベントに関連付けて、イベントハンドラ内でSafeSerializationEventArgs.AddSerializedState(ISafeSerializationData)の引数にデータ型の部分を渡すことになっていて、これが逆シリアル化の際に、ISafeSerializationData.CompleteDeserializationを呼ぶ対象としてマークさせる方法です。
SafeSerializationEventArgs クラスの説明を読むと、"イベントは、セキュリティ透過的なコードにカスタムデータを含む例外をシリアル化するときに発生します。"と書いてあります。
よく見ると、ISafeSerializationData インターフェイスの冒頭に"透過的セキュリティ コードでのカスタム例外データのシリアル化を有効にします。"、と書いてありますね。
つまり、今まで出てきたISafeSerializationData インターフェイス、SerializeObjectState イベント、SafeSerializationEventArgs クラス辺りが既に例外をシリアル化および逆シリアル化で使用することを前提としている訳です。
例外以外はどうするのかと言えば、別に今まで通りでいいのでは?
mscorlibを見てみよう
.NET FrameworkのSystem名前空間にある基本的な型は、GitHubのreferencesource/mscorlib/systemに公開されています。
SerializeObjectState イベントの場所
Exception.SerializeObjectState イベントはException.csの541行目に書いてあります。
今度は_safeSerializationManagerという謎のメンバーが出てきました。
Exception.csの902行目に宣言されていますね。
SafeSerializationManager クラスの場所
SafeSerializationManager クラスまで掘り下げてみます。
internal sealedなので、既存クラスの継承以外でこのクラスを活用を行うことはできませんし、クラス名でGREPしてもException クラスにしか使われていないです。
中身は何をしているかというと、ISerializable インターフェイスで書いていたような内容を代表して実行して、SafeSerializationEventArgs.AddSerializedStateで登録されたデータ群をシリアル化したり逆シリアル化したりしています。
ArgumentException クラスの内容
追加のメンバーがあって最もよく呼び出すであろうException クラスの継承クラスと言えば、ArgumentException クラスだと思うので見てみましょう。
…SerializeObjectState イベントとの関連付けも、ISafeSerializationData インターフェイスの実装もないですね。
つまり、文字列メンバーの追加程度なら要らない?
実例はどこだ!
ISafeSerializationDataで全体をGREPしたところ、引っ掛かったのが
- System.Web.UI.WebControls.EntityDataSourceValidationException クラスの中
- System.Net.Http.HttpRequestException クラスの中
の2クラスですが、特にHttpRequestExceptionはなんなんですかEmptyStateって…中身ないじゃないですか。実質これを守っているの1クラスじゃないですか。
唯一の実例であるEntityDataSourceValidationExceptionは、ISafeSerializationDataの言い出しっぺであるExceptionを独自に内包しているのが実装の理由のように見えますね。
つまり、内部にInnerExceptionとしてコンストラクタに渡さないExceptionを別途持っていると必要なものなのでは?
結論
.NET Framework 4.0以降でException継承クラスを作る際に、新しいメンバーを追加するなら、以下のことをやってね!
- ISafeSerializationData インターフェイスを実装したデータ型を内部に定義しよう
メイン型の名前+Stateというクラス名が公式っぽいぞ! - メイン型に NonSerialized 属性でデータ型のメンバーを1つ持って読み書きしよう
コンストラクタかそれ以前のどこかでデータ型のメンバーをnewをしておこう - データ型のISafeSerializationData.CompleteDeserialization(object deserialized)では
メイン型に用意したメンバーとして自分を設定しよう - 念のため(SerializationInfo, StreamingContext) コンストラクタと
GetObjectData(SerializationInfo, StreamingContext) メソッドは用意しておこう - Exception.SerializeObjectState イベントとの関連付けをしておいて
中ではSafeSerializationEventArgs.AddSerializedState(ISafeSerializationData)を実行してデータ型を渡すようにしておこう - …というのが公式の言い分だけど、サンプルソース以外は公式が全然従ってないぞ!
- ArgumentExceptionのソースを見る限り、基本型の追加程度なら別にやらなくて良さそう
- 唯一の公式での実例は、Exceptionをコンストラクタに渡さない独自の形式で保有する場合なので、その場合は気を付けた方がいいかもしれない
あと、ここまで書いておいてなんですが、SecurityCriticalは.NET Framework 4.xとしてみた時のことで、.NET Coreとか.NET Standardで見るとGetObjectDataに表示されません。
"部分的に信頼されたコードはサポートされなくなりました。 この属性は、.NET Core には影響しません。"…。